@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,185 @@
|
|
|
1
|
+
// 建筑外观预设(纯数据+纯函数,不引 Three.js,Node 可直接 import 测试)。
|
|
2
|
+
//
|
|
3
|
+
// 这一轮的外观参数全部集中在渲染器配置里,不进蓝图(蓝图只收 style 两个取值、拒绝未知字段)。
|
|
4
|
+
// 两套预设由模型已有的 style 值驱动:'warm-modern' 与 'chinese-timber',不新增 style 取值。
|
|
5
|
+
// 两套共享同一套"微缩建筑模型"语言——同一块模型底座、同一种倒角尺度、同一套灯光结构、
|
|
6
|
+
// 同一种门牌版式——只在材质、细部与色温上分开,读起来是同一产品的两种饰面。
|
|
7
|
+
//
|
|
8
|
+
// 颜色的产品含义(由数据给出,渲染器不改写):
|
|
9
|
+
// · 构件底色一律取 part.color(数据已按 style 给了调色板),贴图只做明度上的细微调制;
|
|
10
|
+
// · 房间地面色 = space.accent(按 accentStrength 与饰面底色轻度调和,色相保持可辨);
|
|
11
|
+
// · 墙的状态色 #e4b84e(已实现未完成)/ #a1c9d0(未实现)走独立的"状态材质",
|
|
12
|
+
// 两套预设共用,不被饰面盖掉——"还没建"不能画得像"建好了"。
|
|
13
|
+
|
|
14
|
+
/** 蓝图契约允许的 style 取值(不在这里发明新值)。 */
|
|
15
|
+
export const STYLE_VALUES = Object.freeze(['warm-modern', 'chinese-timber'])
|
|
16
|
+
|
|
17
|
+
/** 数据里带产品含义的颜色(与服务端 presentBuilding 同口径;小写 #rrggbb)。 */
|
|
18
|
+
export const MEANINGFUL_COLORS = Object.freeze({
|
|
19
|
+
statusInProgress: '#e4b84e',
|
|
20
|
+
statusNotBuilt: '#a1c9d0',
|
|
21
|
+
brick: '#938777',
|
|
22
|
+
timber: '#a08060',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 按颜色判定一块构件的"材质角色"。数据目前没有显式的状态字段,只能按颜色精确匹配——
|
|
27
|
+
* 这是本轮最脆弱的一处,已在文档"输入契约建议"里提出加 part.finish / part.status。
|
|
28
|
+
*/
|
|
29
|
+
export function colorRole(color) {
|
|
30
|
+
const hex = normalizeHex(color)
|
|
31
|
+
if (hex === MEANINGFUL_COLORS.statusInProgress) return 'status-in-progress'
|
|
32
|
+
if (hex === MEANINGFUL_COLORS.statusNotBuilt) return 'status-not-built'
|
|
33
|
+
if (hex === MEANINGFUL_COLORS.brick) return 'brick'
|
|
34
|
+
if (hex === MEANINGFUL_COLORS.timber) return 'timber'
|
|
35
|
+
return 'palette'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeHex(color) {
|
|
39
|
+
const text = String(color || '').trim().toLowerCase()
|
|
40
|
+
if (/^#[0-9a-f]{6}$/.test(text)) return text
|
|
41
|
+
if (/^#[0-9a-f]{3}$/.test(text)) return `#${text[1]}${text[1]}${text[2]}${text[2]}${text[3]}${text[3]}`
|
|
42
|
+
return '#b3bea8'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** sRGB 空间里的线性插值(t=0 取 a,t=1 取 b)。 */
|
|
46
|
+
export function mixHex(a, b, t) {
|
|
47
|
+
const pa = parseHex(a)
|
|
48
|
+
const pb = parseHex(b)
|
|
49
|
+
const out = pa.map((value, i) => Math.round(value + (pb[i] - value) * t))
|
|
50
|
+
return `#${out.map((value) => value.toString(16).padStart(2, '0')).join('')}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 明度缩放(factor<1 变暗,>1 变亮,按通道夹取)。 */
|
|
54
|
+
export function shadeHex(color, factor) {
|
|
55
|
+
const out = parseHex(color).map((value) => Math.max(0, Math.min(255, Math.round(value * factor))))
|
|
56
|
+
return `#${out.map((value) => value.toString(16).padStart(2, '0')).join('')}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseHex(color) {
|
|
60
|
+
const hex = normalizeHex(color)
|
|
61
|
+
return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── 两套预设共用的部分 ─────────────────────────────────────────────
|
|
65
|
+
// 尺寸单位一律为米;"倍数"类参数按建筑包络 span 缩放。
|
|
66
|
+
const SHARED = {
|
|
67
|
+
// 倒角:只倒"整块"构件的边(整面墙、整块楼板、整根柱),不给墙的碎段各自倒角。
|
|
68
|
+
edge: { wall: 0.02, slab: 0.025, member: 0.012, trim: 0.008, segments: 1 },
|
|
69
|
+
// 模型底座:整栋楼放在一块两层的展示板上(微缩模型的"桌面感"来自这里)。
|
|
70
|
+
base: { margin: 3.2, radius: 0.9, thickness: 0.3, underLayer: 0.1, underInset: 0.12 },
|
|
71
|
+
// 状态材质(两套共用,不随饰面变化):斜纹=在建,蓝图格=未实现。
|
|
72
|
+
status: {
|
|
73
|
+
inProgress: { stripe: '#c9982f', stripeWidth: 0.14, roughness: 0.8 },
|
|
74
|
+
notBuilt: { line: '#6f98a1', grid: 0.5, roughness: 0.9 },
|
|
75
|
+
},
|
|
76
|
+
// 设计框架(design 等阶段的 frame/plan-floor):制图格+描边,保持"未建成"的读感。
|
|
77
|
+
plan: { grid: 0.5, line: '#6c7d72', edgeOpacity: 0.55 },
|
|
78
|
+
// fill:总览时底座外包在画面里占的比例(NDC,1 = 贴边)。
|
|
79
|
+
camera: { orbitFov: 38, overviewDirection: [0.78, 0.66, 1.0], fill: 0.86 },
|
|
80
|
+
shadow: { mapSize: 2048, radius: 2, walkHalfSize: 12, bias: -0.0005, normalBias: 0.025 },
|
|
81
|
+
// 门牌:同一版式,两套只换配色。
|
|
82
|
+
signLayout: { width: 1.9, projectWidth: 2.7, backingDepth: 0.05, backingPad: 0.07 },
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── warm-modern:暖调现代——石灰白墙、浅色水磨石、直立锁边金属屋面、细木窗框 ────────
|
|
86
|
+
const WARM_MODERN = {
|
|
87
|
+
id: 'warm-modern',
|
|
88
|
+
label: '暖调现代',
|
|
89
|
+
light: {
|
|
90
|
+
sun: '#fff0da', sunIntensity: 2.6, sunElevation: 47, sunAzimuth: 322,
|
|
91
|
+
hemiSky: '#eef2f2', hemiGround: '#cdbfab', hemiIntensity: 1.25,
|
|
92
|
+
envIntensity: 0.32, exposure: 1.02,
|
|
93
|
+
},
|
|
94
|
+
sky: { top: '#d9e4e6', horizon: '#f3eee4', fogNear: 2.4, fogFar: 7.5 },
|
|
95
|
+
ground: { color: '#ebe5da', grid: 1, gridColor: '#d9d1c3', aoStrength: 0.34, side: '#cfc5b5', under: '#6b675f' },
|
|
96
|
+
plinth: { height: 0.24, outset: 0.07, color: '#d4ccbf', texture: 'concrete', topBand: null },
|
|
97
|
+
wall: {
|
|
98
|
+
texture: 'plaster', roughness: 0.93, bumpScale: 0.35, revealShade: 0.9,
|
|
99
|
+
base: { height: 0.12, outset: 0.012, color: '#8f8a80', texture: 'concrete' },
|
|
100
|
+
},
|
|
101
|
+
frame: { texture: 'oak', roughness: 0.72, exposePosts: false, postOutset: 0, beamOutset: 0, beamDrop: 0, columnBase: null },
|
|
102
|
+
opening: { sill: { height: 0.05, projection: 0.06, overhang: 0.08, color: '#d9d2c6' }, threshold: '#b9b0a2', mullion: { width: 0.05 }, lattice: null },
|
|
103
|
+
glass: { tint: null, roughness: 0.06, envBoost: 1.6 },
|
|
104
|
+
roof: {
|
|
105
|
+
finish: 'seam', seamSpacing: 0.42, tileRoughness: 0.55, tileMetalness: 0.18, tileLayer: 0.05,
|
|
106
|
+
soffit: '#ebe4d8', soffitTexture: 'plaster',
|
|
107
|
+
fascia: { height: 0.2, depth: 0.05, shade: 0.82, texture: 'concrete' },
|
|
108
|
+
barge: { height: 0.18, depth: 0.045, shade: 0.82 },
|
|
109
|
+
ridge: { kind: 'cap', width: 0.18, height: 0.06, shade: 0.78, endBlock: null },
|
|
110
|
+
eaveExtend: 0,
|
|
111
|
+
},
|
|
112
|
+
floor: {
|
|
113
|
+
room: 'terrazzo', circulation: 'stone', yard: 'paving',
|
|
114
|
+
accentStrength: 0.78, finishBase: '#e8e0d2', roughness: 0.82, slabEdge: '#dcd4c7',
|
|
115
|
+
},
|
|
116
|
+
band: { shade: 0.9, texture: 'concrete' },
|
|
117
|
+
stair: { stringer: { height: 0.28, depth: 0.05, offset: 0.03 } },
|
|
118
|
+
sign: {
|
|
119
|
+
board: '#f8f5ee', ink: '#24302b', accent: '#4a7466', rule: '#d8d2c6', muted: '#5f6a63',
|
|
120
|
+
backing: '#5e5a52', emissive: 0.22,
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── chinese-timber:中式木构——石台基、青砖裙墙、外露朱褐木柱与额枋、青瓦屋面、方格窗棂 ──
|
|
125
|
+
const CHINESE_TIMBER = {
|
|
126
|
+
id: 'chinese-timber',
|
|
127
|
+
label: '中式木构',
|
|
128
|
+
light: {
|
|
129
|
+
sun: '#ffe8c8', sunIntensity: 2.5, sunElevation: 40, sunAzimuth: 312,
|
|
130
|
+
hemiSky: '#e9ece6', hemiGround: '#bcae96', hemiIntensity: 1.2,
|
|
131
|
+
envIntensity: 0.26, exposure: 1.0,
|
|
132
|
+
},
|
|
133
|
+
sky: { top: '#d7ddd6', horizon: '#f1ebdf', fogNear: 2.4, fogFar: 7.5 },
|
|
134
|
+
ground: { color: '#e6e0d3', grid: 0, gridColor: '#d6cebf', aoStrength: 0.38, side: '#c7bca9', under: '#5f594f' },
|
|
135
|
+
plinth: { height: 0.45, outset: 0.12, color: '#b9b4aa', texture: 'ashlar', topBand: { height: 0.07, outset: 0.03, color: '#a9a397' } },
|
|
136
|
+
wall: {
|
|
137
|
+
texture: 'plaster', roughness: 0.95, bumpScale: 0.4, revealShade: 0.9,
|
|
138
|
+
base: { height: 0.72, outset: 0.02, color: '#8e8c86', texture: 'brick' },
|
|
139
|
+
},
|
|
140
|
+
frame: { texture: 'lacquer', roughness: 0.6, exposePosts: true, postOutset: 0.04, beamOutset: 0.03, beamDrop: 0.12, columnBase: { size: 1.55, height: 0.14, color: '#aaa59b' } },
|
|
141
|
+
opening: { sill: { height: 0.06, projection: 0.05, overhang: 0.06, color: '#9b978f' }, threshold: '#9f9a90', mullion: null, lattice: { bar: 0.035, spacing: 0.2 } },
|
|
142
|
+
glass: { tint: '#e9dfc8', roughness: 0.35, envBoost: 0.6 },
|
|
143
|
+
roof: {
|
|
144
|
+
finish: 'tile', seamSpacing: 0.24, tileRoughness: 0.86, tileMetalness: 0, tileLayer: 0.07,
|
|
145
|
+
soffit: '#8a6a52', soffitTexture: 'boards',
|
|
146
|
+
fascia: { height: 0.22, depth: 0.06, shade: 0.72, texture: 'wood' },
|
|
147
|
+
barge: { height: 0.26, depth: 0.06, shade: 0.7 },
|
|
148
|
+
ridge: { kind: 'ridge', width: 0.26, height: 0.26, shade: 0.7, endBlock: { length: 0.32, height: 0.2 } },
|
|
149
|
+
eaveExtend: 0.32,
|
|
150
|
+
},
|
|
151
|
+
floor: {
|
|
152
|
+
room: 'square-brick', circulation: 'square-brick', yard: 'brick-paving',
|
|
153
|
+
accentStrength: 0.68, finishBase: '#cfc8bb', roughness: 0.9, slabEdge: '#b3ada2',
|
|
154
|
+
},
|
|
155
|
+
band: { shade: 0.86, texture: 'wood' },
|
|
156
|
+
stair: { stringer: { height: 0.3, depth: 0.06, offset: 0.03 } },
|
|
157
|
+
sign: {
|
|
158
|
+
board: '#3a2c24', ink: '#f1e6cf', accent: '#c8a764', rule: '#8d6f45', muted: '#c9b99a',
|
|
159
|
+
backing: '#2a1f1a', emissive: 0.35,
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const PRESETS = Object.freeze({ 'warm-modern': WARM_MODERN, 'chinese-timber': CHINESE_TIMBER })
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 取外观:style 决定预设;未知 style(蓝图不会给,但模型来自外部时要兜底)回落到
|
|
167
|
+
* warm-modern 并标 fallback,调用方可以提示,不发明新预设。overrides 深合并(原型调试用)。
|
|
168
|
+
*/
|
|
169
|
+
export function resolveLook(style, overrides = null) {
|
|
170
|
+
const known = STYLE_VALUES.includes(style)
|
|
171
|
+
const preset = PRESETS[known ? style : 'warm-modern']
|
|
172
|
+
const look = deepMerge(deepMerge({}, SHARED), preset)
|
|
173
|
+
look.fallback = !known
|
|
174
|
+
look.requestedStyle = style ?? null
|
|
175
|
+
return overrides ? deepMerge(look, overrides) : look
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function deepMerge(target, source) {
|
|
179
|
+
for (const [key, value] of Object.entries(source || {})) {
|
|
180
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
181
|
+
target[key] = deepMerge(target[key] && typeof target[key] === 'object' ? { ...target[key] } : {}, value)
|
|
182
|
+
} else target[key] = Array.isArray(value) ? [...value] : value
|
|
183
|
+
}
|
|
184
|
+
return target
|
|
185
|
+
}
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
// 外观规划(纯函数,不引 Three.js,Node 可直接 import 测试)。
|
|
2
|
+
//
|
|
3
|
+
// 输入是服务端给的 model 与 present(只读,绝不修改);输出是"怎么画"的规划:
|
|
4
|
+
// · 墙:把同一面墙被门窗切开的碎段重新拼回一整面带洞口的墙(整面倒角、整面贴图),
|
|
5
|
+
// 拼不回去的(段不共面、颜色不一、洞口贴边等)原样按段画,不猜;
|
|
6
|
+
// · 屋顶:识别双坡对、屋脊线、檐口线,供檐口板、博风板、脊、山墙填充使用;
|
|
7
|
+
// · 楼梯:从踏步推出两侧斜梁;
|
|
8
|
+
// · 窗:从玻璃推出窗台与窗棂位置;门:从墙底缺口推出门槛;
|
|
9
|
+
// · 台基:从首层楼板推出(只认完整外观的 floor,不认设计框架的 plan-floor——
|
|
10
|
+
// 设计阶段画出石台基等于谎称地基已落)。
|
|
11
|
+
// 规划只描述"看得见的东西"。present.colliders 与 present.surfaces 不在这里读写,
|
|
12
|
+
// 派生构件一律不进碰撞与行走数据;派生件只落在墙面上、楼板下、头顶以上,
|
|
13
|
+
// 不在行走身体高度的空地上凭空立东西(见文档"派生规则")。
|
|
14
|
+
|
|
15
|
+
const R = (value) => Math.round(value * 1e4) / 1e4
|
|
16
|
+
const near = (a, b, tolerance = 1e-3) => Math.abs(a - b) <= tolerance
|
|
17
|
+
const hasRotation = (part) => Boolean(part.rotation) && ['x', 'y', 'z'].some((key) => Math.abs(part.rotation[key] || 0) > 1e-6)
|
|
18
|
+
const hex = (color) => String(color || '').trim().toLowerCase()
|
|
19
|
+
|
|
20
|
+
/** 规划入口。look 来自 building-look.js 的 resolveLook。 */
|
|
21
|
+
export function planBuilding(model, present, look) {
|
|
22
|
+
const parts = Array.isArray(present?.parts) ? present.parts : []
|
|
23
|
+
const kinds = new Set(parts.map((part) => part.kind))
|
|
24
|
+
// 完整外观(围护已出现)与设计框架的区分只看数据里出现了哪些构件,不看开关状态:
|
|
25
|
+
// 中间施工阶段可能只出现一部分墙——那就只给那部分墙配细部。
|
|
26
|
+
const envelope = ['wall', 'roof', 'glass', 'floor'].some((kind) => kinds.has(kind))
|
|
27
|
+
const consumed = new Set()
|
|
28
|
+
|
|
29
|
+
const walls = planWalls(parts.filter((part) => part.kind === 'wall'), consumed)
|
|
30
|
+
const roofs = planRoofs(parts.filter((part) => part.kind === 'roof'), look, consumed)
|
|
31
|
+
const gables = planGables(roofs, walls.merged)
|
|
32
|
+
const stairs = planStairs(parts.filter((part) => part.kind === 'stair'), look)
|
|
33
|
+
const windows = planWindows(parts.filter((part) => part.kind === 'glass'), walls.merged, model)
|
|
34
|
+
const thresholds = walls.merged.flatMap((wall) => wall.notches.map((notch) => ({
|
|
35
|
+
id: `threshold:${wall.id}:${notch.u0}`,
|
|
36
|
+
axis: wall.axis, at: wall.at, u0: notch.u0, u1: notch.u1, y: wall.y0, thickness: wall.thickness,
|
|
37
|
+
spaceIds: wall.spaceIds,
|
|
38
|
+
})))
|
|
39
|
+
const lowest = lowestLevel(model, parts)
|
|
40
|
+
const plinths = parts
|
|
41
|
+
.filter((part) => part.kind === 'floor' && !hasRotation(part) && near(part.position.y + part.size.y / 2, lowest, 0.02))
|
|
42
|
+
.map((part) => ({
|
|
43
|
+
id: `plinth:${part.id}`,
|
|
44
|
+
x0: part.position.x - part.size.x / 2, x1: part.position.x + part.size.x / 2,
|
|
45
|
+
z0: part.position.z - part.size.z / 2, z1: part.position.z + part.size.z / 2,
|
|
46
|
+
top: part.position.y - part.size.y / 2,
|
|
47
|
+
spaceIds: part.spaceIds || [],
|
|
48
|
+
}))
|
|
49
|
+
const bounds = model?.bounds || { x0: -10, x1: 10, z0: -10, z1: 10, y0: 0, y1: 4 }
|
|
50
|
+
const groundY = R((bounds.y0 ?? 0) - (look?.plinth?.height ?? 0.24))
|
|
51
|
+
return {
|
|
52
|
+
envelope,
|
|
53
|
+
walls,
|
|
54
|
+
roofs,
|
|
55
|
+
gables,
|
|
56
|
+
stairs,
|
|
57
|
+
windows,
|
|
58
|
+
thresholds,
|
|
59
|
+
plinths,
|
|
60
|
+
groundY,
|
|
61
|
+
lowest,
|
|
62
|
+
rest: parts.filter((part) => !consumed.has(part.id)),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 首层地坪:模型最低楼层标高(缺失时取楼板顶面最低值)。 */
|
|
67
|
+
function lowestLevel(model, parts) {
|
|
68
|
+
const floors = (model?.floors || []).map((floor) => floor.y ?? floor.elevation).filter(Number.isFinite)
|
|
69
|
+
if (floors.length) return Math.min(...floors)
|
|
70
|
+
const tops = parts.filter((part) => part.kind === 'floor' || part.kind === 'plan-floor').map((part) => part.position.y + part.size.y / 2)
|
|
71
|
+
return tops.length ? Math.min(...tops) : 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── 墙:碎段拼回整面 ──────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** 同一面墙的碎段 id 形如 "<墙id>:piece:<序号>";不合这个形状的段自成一组。 */
|
|
77
|
+
export function wallGroupKey(part) {
|
|
78
|
+
const match = /^(.*):piece:\d+$/.exec(String(part.id))
|
|
79
|
+
return match ? match[1] : String(part.id)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function planWalls(wallParts, consumed = new Set()) {
|
|
83
|
+
const groups = new Map()
|
|
84
|
+
for (const part of wallParts) {
|
|
85
|
+
const key = wallGroupKey(part)
|
|
86
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
87
|
+
groups.get(key).push(part)
|
|
88
|
+
}
|
|
89
|
+
const merged = []
|
|
90
|
+
const loose = []
|
|
91
|
+
const reasons = new Map()
|
|
92
|
+
for (const [key, pieces] of groups) {
|
|
93
|
+
const result = mergeWall(key, pieces)
|
|
94
|
+
if (result.ok) {
|
|
95
|
+
merged.push(result.wall)
|
|
96
|
+
} else {
|
|
97
|
+
reasons.set(key, result.reason)
|
|
98
|
+
// 拼不回去就按段画:这一段走 loose 这条绘制路径。
|
|
99
|
+
loose.push(...pieces)
|
|
100
|
+
}
|
|
101
|
+
// 无论拼成没拼成,碎段都算有归宿(loose 也是归宿):漏记就会同时落进 rest,
|
|
102
|
+
// 让 kit 从 loose、rest 各画一遍——洞口的墙段凭空厚一层,还会叠一次明暗。
|
|
103
|
+
for (const piece of pieces) consumed.add(piece.id)
|
|
104
|
+
}
|
|
105
|
+
extendCorners(merged)
|
|
106
|
+
return { merged, loose, failures: [...reasons].map(([id, reason]) => ({ id, reason })) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 一组碎段 → 一面墙。条件:无旋转、同色同不透明度、共面同厚度;
|
|
111
|
+
* 未覆盖区域只能是"墙内洞口"(窗)或"贴墙底的缺口"(门),且都是矩形。
|
|
112
|
+
* 任何一条不满足就返回原因,调用方按段画——宁可退回旧画法,也不猜几何。
|
|
113
|
+
*/
|
|
114
|
+
export function mergeWall(id, pieces) {
|
|
115
|
+
if (!pieces.length) return { ok: false, reason: 'empty' }
|
|
116
|
+
const first = pieces[0]
|
|
117
|
+
if (pieces.some(hasRotation)) return { ok: false, reason: 'rotated' }
|
|
118
|
+
if (pieces.some((piece) => hex(piece.color) !== hex(first.color) || (piece.opacity ?? 1) !== (first.opacity ?? 1))) {
|
|
119
|
+
return { ok: false, reason: 'mixed-appearance' }
|
|
120
|
+
}
|
|
121
|
+
const alongX = pieces.every((piece) => near(piece.position.z, first.position.z) && near(piece.size.z, first.size.z))
|
|
122
|
+
const alongZ = pieces.every((piece) => near(piece.position.x, first.position.x) && near(piece.size.x, first.size.x))
|
|
123
|
+
let axis = null
|
|
124
|
+
if (alongX && alongZ) axis = first.size.x >= first.size.z ? 'x' : 'z'
|
|
125
|
+
else if (alongX) axis = 'x'
|
|
126
|
+
else if (alongZ) axis = 'z'
|
|
127
|
+
if (!axis) return { ok: false, reason: 'not-coplanar' }
|
|
128
|
+
const thickness = axis === 'x' ? first.size.z : first.size.x
|
|
129
|
+
const at = axis === 'x' ? first.position.z : first.position.x
|
|
130
|
+
const rects = pieces.map((piece) => {
|
|
131
|
+
const center = axis === 'x' ? piece.position.x : piece.position.z
|
|
132
|
+
const length = axis === 'x' ? piece.size.x : piece.size.z
|
|
133
|
+
return {
|
|
134
|
+
u0: R(center - length / 2), u1: R(center + length / 2),
|
|
135
|
+
y0: R(piece.position.y - piece.size.y / 2), y1: R(piece.position.y + piece.size.y / 2),
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
if (rects.some((rect) => rect.u1 - rect.u0 <= 0 || rect.y1 - rect.y0 <= 0)) return { ok: false, reason: 'degenerate' }
|
|
139
|
+
const voids = findVoids(rects)
|
|
140
|
+
if (!voids.ok) return { ok: false, reason: voids.reason }
|
|
141
|
+
const u0 = Math.min(...rects.map((rect) => rect.u0))
|
|
142
|
+
const u1 = Math.max(...rects.map((rect) => rect.u1))
|
|
143
|
+
const y0 = Math.min(...rects.map((rect) => rect.y0))
|
|
144
|
+
const y1 = Math.max(...rects.map((rect) => rect.y1))
|
|
145
|
+
const spaceIds = [...new Set(pieces.flatMap((piece) => piece.spaceIds || []))]
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
wall: {
|
|
149
|
+
id, axis, at: R(at), thickness: R(thickness), u0, u1, y0, y1,
|
|
150
|
+
holes: voids.holes, notches: voids.notches,
|
|
151
|
+
extendLo: 0, extendHi: 0,
|
|
152
|
+
color: first.color, opacity: first.opacity ?? 1,
|
|
153
|
+
spaceIds, partIds: pieces.map((piece) => piece.id),
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 在碎段外包矩形里找没被覆盖的区域:按所有边界切网格,连通的空格子就是一个洞。 */
|
|
159
|
+
export function findVoids(rects) {
|
|
160
|
+
const us = [...new Set(rects.flatMap((rect) => [rect.u0, rect.u1]))].sort((a, b) => a - b)
|
|
161
|
+
const ys = [...new Set(rects.flatMap((rect) => [rect.y0, rect.y1]))].sort((a, b) => a - b)
|
|
162
|
+
const nu = us.length - 1
|
|
163
|
+
const ny = ys.length - 1
|
|
164
|
+
const covered = (i, j) => {
|
|
165
|
+
const cu = (us[i] + us[i + 1]) / 2
|
|
166
|
+
const cy = (ys[j] + ys[j + 1]) / 2
|
|
167
|
+
return rects.some((rect) => cu > rect.u0 && cu < rect.u1 && cy > rect.y0 && cy < rect.y1)
|
|
168
|
+
}
|
|
169
|
+
const empty = new Set()
|
|
170
|
+
for (let i = 0; i < nu; i += 1) for (let j = 0; j < ny; j += 1) if (!covered(i, j)) empty.add(`${i},${j}`)
|
|
171
|
+
const holes = []
|
|
172
|
+
const notches = []
|
|
173
|
+
const seen = new Set()
|
|
174
|
+
for (const start of empty) {
|
|
175
|
+
if (seen.has(start)) continue
|
|
176
|
+
const stack = [start]
|
|
177
|
+
const cells = []
|
|
178
|
+
seen.add(start)
|
|
179
|
+
while (stack.length) {
|
|
180
|
+
const cell = stack.pop()
|
|
181
|
+
cells.push(cell)
|
|
182
|
+
const [i, j] = cell.split(',').map(Number)
|
|
183
|
+
for (const [di, dj] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
|
|
184
|
+
const next = `${i + di},${j + dj}`
|
|
185
|
+
if (empty.has(next) && !seen.has(next)) {
|
|
186
|
+
seen.add(next)
|
|
187
|
+
stack.push(next)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const is = cells.map((cell) => Number(cell.split(',')[0]))
|
|
192
|
+
const js = cells.map((cell) => Number(cell.split(',')[1]))
|
|
193
|
+
const i0 = Math.min(...is), i1 = Math.max(...is), j0 = Math.min(...js), j1 = Math.max(...js)
|
|
194
|
+
if (cells.length !== (i1 - i0 + 1) * (j1 - j0 + 1)) return { ok: false, reason: 'non-rectangular-opening' }
|
|
195
|
+
const touchesLeft = i0 === 0
|
|
196
|
+
const touchesRight = i1 === nu - 1
|
|
197
|
+
const touchesBottom = j0 === 0
|
|
198
|
+
const touchesTop = j1 === ny - 1
|
|
199
|
+
const rect = { u0: us[i0], u1: us[i1 + 1], y0: ys[j0], y1: ys[j1 + 1] }
|
|
200
|
+
if (touchesLeft || touchesRight || touchesTop) return { ok: false, reason: 'opening-at-edge' }
|
|
201
|
+
if (touchesBottom) notches.push({ u0: rect.u0, u1: rect.u1, y1: rect.y1 })
|
|
202
|
+
else holes.push(rect)
|
|
203
|
+
}
|
|
204
|
+
notches.sort((a, b) => a.u0 - b.u0)
|
|
205
|
+
holes.sort((a, b) => a.u0 - b.u0 || a.y0 - b.y0)
|
|
206
|
+
return { ok: true, holes, notches }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 转角补齐:墙按中心线切分,两面垂直墙在外角会各缺半个墙厚。墙端碰到另一面垂直墙
|
|
211
|
+
* (同层、高度有重叠)且同一端没有共线的续墙时,把端头延长到对方墙面(少 2 毫米防闪面)。
|
|
212
|
+
* 这是纯视觉延长,碰撞盒不变(碰撞数据根本不经过这里)。
|
|
213
|
+
*/
|
|
214
|
+
function extendCorners(walls) {
|
|
215
|
+
for (const wall of walls) {
|
|
216
|
+
for (const end of ['lo', 'hi']) {
|
|
217
|
+
const endU = end === 'lo' ? wall.u0 : wall.u1
|
|
218
|
+
const collinear = walls.some((other) => other !== wall && other.axis === wall.axis && near(other.at, wall.at, 0.01)
|
|
219
|
+
&& (end === 'lo' ? near(other.u1, endU, 0.01) : near(other.u0, endU, 0.01)) && overlapY(other, wall))
|
|
220
|
+
if (collinear) continue
|
|
221
|
+
let extend = 0
|
|
222
|
+
for (const other of walls) {
|
|
223
|
+
if (other === wall || other.axis === wall.axis || !overlapY(other, wall)) continue
|
|
224
|
+
if (!near(other.at, endU, 0.01)) continue
|
|
225
|
+
if (wall.at < other.u0 - 0.01 || wall.at > other.u1 + 0.01) continue
|
|
226
|
+
extend = Math.max(extend, other.thickness / 2 - 0.002)
|
|
227
|
+
}
|
|
228
|
+
if (end === 'lo') wall.extendLo = R(extend)
|
|
229
|
+
else wall.extendHi = R(extend)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const overlapY = (a, b) => Math.min(a.y1, b.y1) - Math.max(a.y0, b.y0) > 0.1
|
|
235
|
+
|
|
236
|
+
// ── 屋顶:坡板、屋脊、檐口 ────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
/** Euler XYZ(Three.js 默认顺序)→ 3×3 旋转矩阵,行主序。 */
|
|
239
|
+
export function eulerMatrix({ x = 0, y = 0, z = 0 } = {}) {
|
|
240
|
+
const a = Math.cos(x), b = Math.sin(x)
|
|
241
|
+
const c = Math.cos(y), d = Math.sin(y)
|
|
242
|
+
const e = Math.cos(z), f = Math.sin(z)
|
|
243
|
+
const ae = a * e, af = a * f, be = b * e, bf = b * f
|
|
244
|
+
return [
|
|
245
|
+
[c * e, -c * f, d],
|
|
246
|
+
[af + be * d, ae - bf * d, -b * c],
|
|
247
|
+
[bf - ae * d, be + af * d, a * c],
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const column = (m, i) => [m[0][i], m[1][i], m[2][i]]
|
|
252
|
+
const scale3 = (v, s) => v.map((value) => value * s)
|
|
253
|
+
const add3 = (a, b) => a.map((value, i) => value + b[i])
|
|
254
|
+
|
|
255
|
+
/** 一块坡板的几何框架:上坡方向、屋脊方向、坡长、高低两条边的中点。平板返回 slope=false。 */
|
|
256
|
+
export function slabFrame(part) {
|
|
257
|
+
const m = eulerMatrix(part.rotation || {})
|
|
258
|
+
const ax = column(m, 0)
|
|
259
|
+
const ay = column(m, 1)
|
|
260
|
+
const az = column(m, 2)
|
|
261
|
+
const center = [part.position.x, part.position.y, part.position.z]
|
|
262
|
+
const slopeLocal = Math.abs(ax[1]) > Math.abs(az[1]) ? 'x' : 'z'
|
|
263
|
+
let dir = slopeLocal === 'x' ? ax : az
|
|
264
|
+
const ridgeDir = slopeLocal === 'x' ? az : ax
|
|
265
|
+
const L = slopeLocal === 'x' ? part.size.x : part.size.z
|
|
266
|
+
const W = slopeLocal === 'x' ? part.size.z : part.size.x
|
|
267
|
+
const slope = Math.abs(dir[1]) > 1e-4
|
|
268
|
+
if (dir[1] < 0) dir = scale3(dir, -1)
|
|
269
|
+
const normal = ay[1] < 0 ? scale3(ay, -1) : ay
|
|
270
|
+
const cosT = Math.hypot(dir[0], dir[2]) || 1
|
|
271
|
+
return {
|
|
272
|
+
center, dir, ridgeDir, normal, L, W, th: part.size.y, cosT, slope, slopeLocal,
|
|
273
|
+
high: add3(center, scale3(dir, L / 2)),
|
|
274
|
+
low: add3(center, scale3(dir, -L / 2)),
|
|
275
|
+
matrix: m,
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function planRoofs(roofParts, look, consumed = new Set()) {
|
|
280
|
+
const groups = new Map()
|
|
281
|
+
for (const part of roofParts) {
|
|
282
|
+
const match = /^(roof:.+):-?1$/.exec(String(part.id))
|
|
283
|
+
const key = match ? match[1] : String(part.id)
|
|
284
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
285
|
+
groups.get(key).push(part)
|
|
286
|
+
}
|
|
287
|
+
const roofs = []
|
|
288
|
+
for (const [id, slabsParts] of groups) {
|
|
289
|
+
const slabs = slabsParts.map((part) => ({ part, frame: slabFrame(part) }))
|
|
290
|
+
for (const slab of slabs) consumed.add(slab.part.id)
|
|
291
|
+
const first = slabs[0]
|
|
292
|
+
let kind = first.frame.slope ? 'mono' : 'flat'
|
|
293
|
+
let ridge = null
|
|
294
|
+
if (slabs.length === 2 && slabs.every((slab) => slab.frame.slope)) {
|
|
295
|
+
const [a, b] = slabs.map((slab) => slab.frame)
|
|
296
|
+
const meet = Math.hypot(a.high[0] - b.high[0], a.high[1] - b.high[1], a.high[2] - b.high[2]) < 0.05
|
|
297
|
+
const opposite = a.dir[0] * b.dir[0] + a.dir[2] * b.dir[2] < 0
|
|
298
|
+
if (meet && opposite) {
|
|
299
|
+
kind = 'gable'
|
|
300
|
+
const mid = scale3(add3(a.high, b.high), 0.5)
|
|
301
|
+
const half = Math.max(a.W, b.W) / 2
|
|
302
|
+
const topY = mid[1] + (a.th / 2) / a.cosT
|
|
303
|
+
ridge = {
|
|
304
|
+
a: add3(mid, scale3(a.ridgeDir, -half)),
|
|
305
|
+
b: add3(mid, scale3(a.ridgeDir, half)),
|
|
306
|
+
center: mid, length: half * 2, topY, dir: a.ridgeDir,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// 轴对齐的坡向(坡沿 x 或 z)才做山墙填充;斜向屋顶只画坡板与檐口,不猜山墙。
|
|
311
|
+
const d = first.frame.dir
|
|
312
|
+
const slopeAxis = Math.abs(d[0]) > 0.999 * first.frame.cosT ? 'x' : Math.abs(d[2]) > 0.999 * first.frame.cosT ? 'z' : null
|
|
313
|
+
const eaveY = Math.min(...slabs.map((slab) => slab.frame.low[1]))
|
|
314
|
+
const ridgeIndex = slopeAxis === 'x' ? 2 : 0
|
|
315
|
+
const slopeIndex = slopeAxis === 'x' ? 0 : 2
|
|
316
|
+
const ridgeRange = slopeAxis ? [
|
|
317
|
+
Math.min(...slabs.map((slab) => slab.frame.center[ridgeIndex] - slab.frame.W / 2)),
|
|
318
|
+
Math.max(...slabs.map((slab) => slab.frame.center[ridgeIndex] + slab.frame.W / 2)),
|
|
319
|
+
] : null
|
|
320
|
+
const slopeRange = slopeAxis ? [
|
|
321
|
+
Math.min(...slabs.flatMap((slab) => [slab.frame.high[slopeIndex], slab.frame.low[slopeIndex]])),
|
|
322
|
+
Math.max(...slabs.flatMap((slab) => [slab.frame.high[slopeIndex], slab.frame.low[slopeIndex]])),
|
|
323
|
+
] : null
|
|
324
|
+
roofs.push({
|
|
325
|
+
id, kind, slabs, ridge, slopeAxis, eaveY, ridgeRange, slopeRange,
|
|
326
|
+
eaveExtend: kind === 'flat' ? 0 : (look?.roof?.eaveExtend ?? 0),
|
|
327
|
+
color: first.part.color, opacity: first.part.opacity ?? 1,
|
|
328
|
+
spaceIds: [...new Set(slabs.flatMap((slab) => slab.part.spaceIds || []))],
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
return roofs
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** 坡板底面在坡向坐标 s 处的高度(多块覆盖取最低,保证山墙填充不顶穿屋面)。 */
|
|
335
|
+
export function roofUnderside(roof, s) {
|
|
336
|
+
const index = roof.slopeAxis === 'x' ? 0 : 2
|
|
337
|
+
let best = null
|
|
338
|
+
for (const { frame } of roof.slabs) {
|
|
339
|
+
const component = frame.dir[index]
|
|
340
|
+
if (Math.abs(component) < 1e-6) continue
|
|
341
|
+
const t = (s - frame.center[index]) / component
|
|
342
|
+
if (Math.abs(t) > frame.L / 2 + 1e-6) continue
|
|
343
|
+
const y = frame.center[1] + t * frame.dir[1] - (frame.th / 2) / frame.cosT
|
|
344
|
+
best = best === null ? y : Math.min(best, y)
|
|
345
|
+
}
|
|
346
|
+
return best
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* 山墙填充:屋顶下、与屋脊垂直、墙顶齐檐口的墙,补上墙顶到坡板底之间的三角/梯形。
|
|
351
|
+
* 只在墙已经出现时补(沿用那面墙的颜色与不透明度,状态色随之延续),
|
|
352
|
+
* 剖看时随屋顶一起隐藏;不进碰撞(在头顶以上,碰撞数据也不经过这里)。
|
|
353
|
+
*/
|
|
354
|
+
export function planGables(roofs, walls) {
|
|
355
|
+
const gables = []
|
|
356
|
+
for (const roof of roofs) {
|
|
357
|
+
if (roof.kind !== 'gable' || !roof.slopeAxis) continue
|
|
358
|
+
const ridgeIndex = roof.slopeAxis === 'x' ? 0 : 2
|
|
359
|
+
const ridgeS = roof.ridge.center[ridgeIndex]
|
|
360
|
+
for (const wall of walls) {
|
|
361
|
+
if (wall.axis !== roof.slopeAxis) continue
|
|
362
|
+
if (wall.at < roof.ridgeRange[0] - 0.01 || wall.at > roof.ridgeRange[1] + 0.01) continue
|
|
363
|
+
if (Math.abs(wall.y1 - roof.eaveY) > 0.35) continue
|
|
364
|
+
const a = Math.max(wall.u0, roof.slopeRange[0])
|
|
365
|
+
const b = Math.min(wall.u1, roof.slopeRange[1])
|
|
366
|
+
if (b - a < 0.05) continue
|
|
367
|
+
const samples = [a, ...(ridgeS > a && ridgeS < b ? [ridgeS] : []), b]
|
|
368
|
+
const top = samples.map((s) => ({ s, y: roofUnderside(roof, s) }))
|
|
369
|
+
if (top.some((point) => point.y === null)) continue
|
|
370
|
+
const floorY = wall.y1
|
|
371
|
+
const clipped = clipAbove(top.map((point) => ({ s: point.s, y: point.y - 0.004 })), floorY + 0.005)
|
|
372
|
+
if (clipped.length < 2) continue
|
|
373
|
+
const polygon = [[clipped[0].s, floorY], ...clipped.map((point) => [point.s, point.y]), [clipped.at(-1).s, floorY]]
|
|
374
|
+
if (polygonArea(polygon) < 0.02) continue
|
|
375
|
+
gables.push({
|
|
376
|
+
id: `gable:${roof.id}:${wall.id}`,
|
|
377
|
+
axis: wall.axis, at: wall.at, thickness: wall.thickness,
|
|
378
|
+
polygon: dedupe(polygon),
|
|
379
|
+
color: wall.color, opacity: wall.opacity,
|
|
380
|
+
spaceIds: roof.spaceIds, roofId: roof.id, wallId: wall.id,
|
|
381
|
+
lowY: floorY,
|
|
382
|
+
})
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return gables
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function clipAbove(points, floorY) {
|
|
389
|
+
const out = []
|
|
390
|
+
for (let i = 0; i < points.length; i += 1) {
|
|
391
|
+
const p = points[i]
|
|
392
|
+
if (i > 0) {
|
|
393
|
+
const q = points[i - 1]
|
|
394
|
+
if ((q.y - floorY) * (p.y - floorY) < 0) {
|
|
395
|
+
const t = (floorY - q.y) / (p.y - q.y)
|
|
396
|
+
out.push({ s: q.s + (p.s - q.s) * t, y: floorY })
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (p.y > floorY) out.push(p)
|
|
400
|
+
}
|
|
401
|
+
return out
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function dedupe(polygon) {
|
|
405
|
+
return polygon.filter((point, i) => {
|
|
406
|
+
const prev = polygon[(i - 1 + polygon.length) % polygon.length]
|
|
407
|
+
return !(near(point[0], prev[0], 1e-5) && near(point[1], prev[1], 1e-5))
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function polygonArea(polygon) {
|
|
412
|
+
let area = 0
|
|
413
|
+
for (let i = 0; i < polygon.length; i += 1) {
|
|
414
|
+
const [x0, y0] = polygon[i]
|
|
415
|
+
const [x1, y1] = polygon[(i + 1) % polygon.length]
|
|
416
|
+
area += x0 * y1 - x1 * y0
|
|
417
|
+
}
|
|
418
|
+
return Math.abs(area) / 2
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── 楼梯:两侧斜梁 ───────────────────────────────────────────────
|
|
422
|
+
|
|
423
|
+
export function planStairs(stepParts, look) {
|
|
424
|
+
const groups = new Map()
|
|
425
|
+
for (const part of stepParts) {
|
|
426
|
+
const match = /^step:(.+):\d+$/.exec(String(part.id))
|
|
427
|
+
const key = match ? match[1] : (part.spaceIds || []).join('|') || String(part.id)
|
|
428
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
429
|
+
groups.get(key).push(part)
|
|
430
|
+
}
|
|
431
|
+
const stairs = []
|
|
432
|
+
const spec = look?.stair?.stringer
|
|
433
|
+
for (const [id, steps] of groups) {
|
|
434
|
+
if (steps.length < 2 || !spec) continue
|
|
435
|
+
const sorted = [...steps].sort((a, b) => a.position.y - b.position.y)
|
|
436
|
+
const first = sorted[0]
|
|
437
|
+
const last = sorted.at(-1)
|
|
438
|
+
const dx = last.position.x - first.position.x
|
|
439
|
+
const dz = last.position.z - first.position.z
|
|
440
|
+
const run = Math.hypot(dx, dz)
|
|
441
|
+
if (run < 1e-3) continue
|
|
442
|
+
const d = [dx / run, dz / run]
|
|
443
|
+
const alongX = Math.abs(d[0]) >= Math.abs(d[1])
|
|
444
|
+
const tread = alongX ? first.size.x : first.size.z
|
|
445
|
+
const width = alongX ? first.size.z : first.size.x
|
|
446
|
+
const start = [first.position.x - d[0] * tread / 2, first.position.y + first.size.y / 2, first.position.z - d[1] * tread / 2]
|
|
447
|
+
const end = [last.position.x + d[0] * tread / 2, last.position.y + last.size.y / 2, last.position.z + d[1] * tread / 2]
|
|
448
|
+
const horizontal = Math.hypot(end[0] - start[0], end[2] - start[2])
|
|
449
|
+
const pitch = Math.atan2(end[1] - start[1], horizontal)
|
|
450
|
+
const length = Math.hypot(horizontal, end[1] - start[1])
|
|
451
|
+
const side = width / 2 + spec.depth / 2 + spec.offset
|
|
452
|
+
const perp = [-d[1], d[0]]
|
|
453
|
+
// 斜梁中线:沿踏步前缘线,再沿斜面法向往下让出梁高的一半多一点,让踏步"坐"在梁上。
|
|
454
|
+
const drop = spec.height * 0.42
|
|
455
|
+
const mid = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2, (start[2] + end[2]) / 2]
|
|
456
|
+
const center = [mid[0] + d[0] * Math.sin(pitch) * drop, mid[1] - Math.cos(pitch) * drop, mid[2] + d[1] * Math.sin(pitch) * drop]
|
|
457
|
+
stairs.push({
|
|
458
|
+
id,
|
|
459
|
+
yaw: Math.atan2(-d[1], d[0]),
|
|
460
|
+
pitch,
|
|
461
|
+
stringers: [-1, 1].map((sign) => ({
|
|
462
|
+
id: `stringer:${id}:${sign}`,
|
|
463
|
+
center: [center[0] + perp[0] * side * sign, center[1], center[2] + perp[1] * side * sign],
|
|
464
|
+
length, height: spec.height, depth: spec.depth,
|
|
465
|
+
})),
|
|
466
|
+
color: first.color, opacity: first.opacity ?? 1,
|
|
467
|
+
spaceIds: [...new Set(steps.flatMap((step) => step.spaceIds || []))],
|
|
468
|
+
lowY: Math.min(start[1], end[1]) - spec.height,
|
|
469
|
+
})
|
|
470
|
+
}
|
|
471
|
+
return stairs
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── 窗:窗台与窗棂的定位 ──────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
export function planWindows(glassParts, walls, model) {
|
|
477
|
+
const fallbackThickness = model?.defaults?.wallThickness ?? 0.18
|
|
478
|
+
return glassParts.filter((part) => !hasRotation(part)).map((part) => {
|
|
479
|
+
const axis = part.size.z <= part.size.x ? 'x' : 'z'
|
|
480
|
+
const at = axis === 'x' ? part.position.z : part.position.x
|
|
481
|
+
const wall = walls.find((item) => item.axis === axis && near(item.at, at, 0.05)
|
|
482
|
+
&& (axis === 'x' ? part.position.x : part.position.z) > item.u0 && (axis === 'x' ? part.position.x : part.position.z) < item.u1)
|
|
483
|
+
return {
|
|
484
|
+
id: `window:${part.id}`,
|
|
485
|
+
part, axis, at,
|
|
486
|
+
uc: axis === 'x' ? part.position.x : part.position.z,
|
|
487
|
+
width: axis === 'x' ? part.size.x : part.size.z,
|
|
488
|
+
y0: part.position.y - part.size.y / 2,
|
|
489
|
+
y1: part.position.y + part.size.y / 2,
|
|
490
|
+
thickness: wall ? wall.thickness : fallbackThickness,
|
|
491
|
+
wallColor: wall ? wall.color : null,
|
|
492
|
+
spaceIds: part.spaceIds || [],
|
|
493
|
+
}
|
|
494
|
+
})
|
|
495
|
+
}
|