@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/README.zh.md +100 -0
- package/client.js +122 -0
- package/cordis.patch.yml +8 -0
- package/dist/check-building.js +1669 -0
- package/dist/check-room.js +980 -0
- package/dist/index.js +2873 -0
- package/package.json +60 -0
- package/skills/specdev-building/SKILL.md +85 -0
- package/skills/specdev-building/assets/layouts/court.json +53 -0
- package/skills/specdev-building/assets/layouts/row.json +48 -0
- package/skills/specdev-building/assets/layouts/single.json +27 -0
- package/skills/specdev-building/assets/layouts/tower-9.json +71 -0
- package/skills/specdev-building/assets/layouts/tower.json +59 -0
- package/skills/specdev-building/references/blueprint-contract.md +126 -0
- package/skills/specdev-building/references/diagnostics.md +101 -0
- package/skills/specdev-building/references/layouts.md +112 -0
- package/skills/specdev-business/SKILL.md +91 -0
- package/skills/specdev-business/assets/plan-template.md +66 -0
- package/skills/specdev-business/assets/spec-template.md +111 -0
- package/skills/specdev-business/assets/tasks-template.md +36 -0
- package/skills/specdev-business/references/change.md +117 -0
- package/skills/specdev-business/references/evidence-review.md +125 -0
- package/skills/specdev-business/references/implementation.md +67 -0
- package/skills/specdev-business/references/project-contract.md +211 -0
- package/skills/specdev-business/references/source-map.md +110 -0
- package/skills/specdev-business/references/specification.md +93 -0
- package/skills/specdev-business/references/workflow.md +126 -0
- package/skills/specdev-room/SKILL.md +77 -0
- package/skills/specdev-room/assets/intro-room.json +14 -0
- package/skills/specdev-room/references/diagnostics.md +21 -0
- package/skills/specdev-room/references/room-contract.md +59 -0
- package/vendor/archify-renderer/VERSION.md +52 -0
- package/vendor/archify-renderer/archify/LICENSE +22 -0
- package/vendor/archify-renderer/archify/THIRD_PARTY_NOTICES.md +56 -0
- package/vendor/archify-renderer/archify/assets/template.html +14787 -0
- package/vendor/archify-renderer/archify/bin/archify.mjs +1990 -0
- package/vendor/archify-renderer/archify/renderers/shared/brand-marks.mjs +563 -0
- package/vendor/archify-renderer/archify/renderers/shared/cli.mjs +218 -0
- package/vendor/archify-renderer/archify/renderers/shared/desktop-readability.mjs +26 -0
- package/vendor/archify-renderer/archify/renderers/shared/diagnostics.mjs +127 -0
- package/vendor/archify-renderer/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/vendor/archify-renderer/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/vendor/archify-renderer/archify/renderers/shared/generated-validators.mjs +13 -0
- package/vendor/archify-renderer/archify/renderers/shared/geometry.mjs +1423 -0
- package/vendor/archify-renderer/archify/renderers/shared/i18n.mjs +594 -0
- package/vendor/archify-renderer/archify/renderers/shared/legend.mjs +217 -0
- package/vendor/archify-renderer/archify/renderers/shared/output-path.mjs +321 -0
- package/vendor/archify-renderer/archify/renderers/shared/repository-evidence.mjs +235 -0
- package/vendor/archify-renderer/archify/renderers/shared/text-fit.mjs +49 -0
- package/vendor/archify-renderer/archify/renderers/shared/utils.mjs +232 -0
- package/vendor/archify-renderer/archify/renderers/shared/validator.mjs +86 -0
- package/vendor/archify-renderer/archify/renderers/workflow/render-workflow.mjs +35 -0
- package/vendor/archify-renderer/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- package/vendor/archify-renderer/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/vendor/archify-renderer/archify/schemas/common.schema.json +115 -0
- package/vendor/archify-renderer/archify/schemas/workflow.schema.json +428 -0
- package/vendor/archify-renderer/archify/scripts/check-render-output.mjs +835 -0
- package/vendor/archify-renderer/shims/process.mjs +18 -0
- package/vendor/archify-renderer/stubs/child_process.mjs +2 -0
- package/vendor/archify-renderer/stubs/crypto.mjs +3 -0
- package/vendor/archify-renderer/stubs/dns-promises.mjs +2 -0
- package/vendor/archify-renderer/stubs/fs.mjs +3 -0
- package/vendor/archify-renderer/stubs/http.mjs +3 -0
- package/vendor/archify-renderer/stubs/https.mjs +3 -0
- package/vendor/archify-renderer/stubs/net.mjs +3 -0
- package/vendor/archify-renderer/stubs/path.mjs +3 -0
- package/vendor/archify-renderer/stubs/url.mjs +3 -0
- package/web/assets/building-kit.js +1157 -0
- package/web/assets/building-look.js +185 -0
- package/web/assets/building-plan.js +495 -0
- package/web/assets/building-scene.js +529 -0
- package/web/assets/building-walk.js +248 -0
- package/web/assets/building.js +859 -0
- package/web/assets/business.js +124 -0
- package/web/assets/chart-link.js +165 -0
- package/web/assets/common.js +302 -0
- package/web/assets/details.js +197 -0
- package/web/assets/evidence.js +30 -0
- package/web/assets/furniture/SOURCES.md +24 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/asset.json +25 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/model.js +116 -0
- package/web/assets/furniture/assets/ceramic-display-stand-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/asset.json +61 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/model.js +56 -0
- package/web/assets/furniture/assets/ceramic-floor-lamp-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/asset.json +81 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/model.js +67 -0
- package/web/assets/furniture/assets/ceramic-low-cabinet-01/preview.webp +0 -0
- package/web/assets/furniture/assets/ceramic-vase-01/asset.json +21 -0
- package/web/assets/furniture/assets/ceramic-vase-01/model.js +119 -0
- package/web/assets/furniture/assets/ceramic-vase-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-bed-01/asset.json +61 -0
- package/web/assets/furniture/assets/fairy-bed-01/model.js +63 -0
- package/web/assets/furniture/assets/fairy-bed-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/model.js +71 -0
- package/web/assets/furniture/assets/fairy-book-stack-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/asset.json +32 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/model.js +168 -0
- package/web/assets/furniture/assets/fairy-bookshelf-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-desk-01/asset.json +25 -0
- package/web/assets/furniture/assets/fairy-desk-01/model.js +132 -0
- package/web/assets/furniture/assets/fairy-desk-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/asset.json +23 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/model.js +125 -0
- package/web/assets/furniture/assets/fairy-notice-board-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/model.js +128 -0
- package/web/assets/furniture/assets/fairy-potted-plant-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/asset.json +61 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/model.js +66 -0
- package/web/assets/furniture/assets/fairy-reading-chair-01/preview.webp +0 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/asset.json +21 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/model.js +182 -0
- package/web/assets/furniture/assets/fairy-table-lamp-01/preview.webp +0 -0
- package/web/assets/furniture/catalog.js +137 -0
- package/web/assets/furniture/index.json +110 -0
- package/web/assets/furniture/registry.js +72 -0
- package/web/assets/furniture-page.css +68 -0
- package/web/assets/furniture-page.js +302 -0
- package/web/assets/home.js +65 -0
- package/web/assets/node-detail.js +148 -0
- package/web/assets/panel-link.js +47 -0
- package/web/assets/read.js +746 -0
- package/web/assets/room-scene.js +386 -0
- package/web/assets/room.css +287 -0
- package/web/assets/room.js +437 -0
- package/web/assets/rooms/bindings.js +449 -0
- package/web/assets/rooms/ceramic-reflection.js +193 -0
- package/web/assets/rooms/ceramic-room.js +302 -0
- package/web/assets/rooms/door.js +35 -0
- package/web/assets/rooms/fairy-room.js +492 -0
- package/web/assets/rooms/furniture-assembly.js +113 -0
- package/web/assets/rooms/navigation.js +262 -0
- package/web/assets/rooms/placeholder-catalog.json +71 -0
- package/web/assets/rooms/placeholder-furniture.js +76 -0
- package/web/assets/rooms/placement.js +588 -0
- package/web/assets/rooms/reader.js +441 -0
- package/web/assets/rooms/reading-gate.js +157 -0
- package/web/assets/rooms/room-lighting.js +50 -0
- package/web/assets/rooms/templates.json +50 -0
- package/web/assets/rooms/walk-frame.js +64 -0
- package/web/assets/rooms/walk-session.js +138 -0
- package/web/assets/save-result.js +89 -0
- package/web/assets/screenshots/business.png +0 -0
- package/web/assets/screenshots/node-detail.png +0 -0
- package/web/assets/screenshots/project-home.png +0 -0
- package/web/assets/screenshots/workflow.png +0 -0
- package/web/assets/showcase.js +232 -0
- package/web/assets/style.css +836 -0
- package/web/assets/template.js +7 -0
- package/web/assets/theme.js +29 -0
- package/web/assets/three/OrbitControls.js +1972 -0
- package/web/assets/three/VERSION.md +12 -0
- package/web/assets/three/three.core.js +60586 -0
- package/web/assets/three/three.module.js +19719 -0
- package/web/assets/workspaces.css +21 -0
- package/web/assets/workspaces.js +81 -0
- package/web/building.html +131 -0
- package/web/business.html +43 -0
- package/web/furniture.html +47 -0
- package/web/index.html +57 -0
- package/web/read.html +107 -0
- package/web/room.html +146 -0
- package/web/showcase.html +50 -0
- 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
|
+
}
|