|
| 1 | +<script setup lang="ts"> |
| 2 | +import * as THREE from 'three' |
| 3 | +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' |
| 4 | +
|
| 5 | +const props = defineProps<{ workshopId: string }>() |
| 6 | +
|
| 7 | +interface Model { |
| 8 | + size: [number, number, number] |
| 9 | + palette: string[] |
| 10 | + blocks: Int32Array |
| 11 | + count: number |
| 12 | +} |
| 13 | +
|
| 14 | +const canvas = ref<HTMLCanvasElement>() |
| 15 | +const state = ref<'loading' | 'ready' | 'empty' | 'error'>('loading') |
| 16 | +const message = ref('') |
| 17 | +const omitted = ref(0) |
| 18 | +const stats = ref<{ name: string; count: number }[]>([]) |
| 19 | +
|
| 20 | +/** Y 轴切片:只显示不高于该层的方块,用于看内部结构 */ |
| 21 | +const sliceY = ref(0) |
| 22 | +const maxY = ref(0) |
| 23 | +
|
| 24 | +let renderer: THREE.WebGLRenderer | null = null |
| 25 | +let controls: OrbitControls | null = null |
| 26 | +let model: Model | null = null |
| 27 | +let meshes: THREE.InstancedMesh[] = [] |
| 28 | +let scene: THREE.Scene | null = null |
| 29 | +let raf = 0 |
| 30 | +
|
| 31 | +// ─── 二进制解码,格式见 server/hono/lib/renderModel.ts ─── |
| 32 | +function decode(buf: ArrayBuffer): Model | null { |
| 33 | + const view = new DataView(buf) |
| 34 | + let off = 0 |
| 35 | + if (view.getUint32(off, true) !== 0x4e534b4d) return null |
| 36 | + off += 4 |
| 37 | + if (view.getUint8(off) !== 1) return null |
| 38 | + off += 1 |
| 39 | +
|
| 40 | + const size: [number, number, number] = [ |
| 41 | + view.getUint16(off, true), |
| 42 | + view.getUint16(off + 2, true), |
| 43 | + view.getUint16(off + 4, true), |
| 44 | + ] |
| 45 | + off += 6 |
| 46 | +
|
| 47 | + const palCount = view.getUint16(off, true) |
| 48 | + off += 2 |
| 49 | + const decoder = new TextDecoder() |
| 50 | + const palette: string[] = [] |
| 51 | + for (let i = 0; i < palCount; i++) { |
| 52 | + const len = view.getUint8(off) |
| 53 | + off += 1 |
| 54 | + palette.push(decoder.decode(new Uint8Array(buf, off, len))) |
| 55 | + off += len |
| 56 | + } |
| 57 | +
|
| 58 | + const count = view.getUint32(off, true) |
| 59 | + off += 4 |
| 60 | + const blocks = new Int32Array(count * 4) |
| 61 | + for (let i = 0; i < count; i++) { |
| 62 | + blocks[i * 4] = view.getUint16(off, true) |
| 63 | + blocks[i * 4 + 1] = view.getUint16(off + 2, true) |
| 64 | + blocks[i * 4 + 2] = view.getUint16(off + 4, true) |
| 65 | + blocks[i * 4 + 3] = view.getUint16(off + 6, true) |
| 66 | + off += 8 |
| 67 | + } |
| 68 | +
|
| 69 | + return { size, palette, blocks, count } |
| 70 | +} |
| 71 | +
|
| 72 | +// ─── 方块配色 ─── |
| 73 | +// 纹理图集(设计条目 H7.4)尚未接入,先按方块名归类取色。 |
| 74 | +// 同类方块用同一基色 + 名称哈希的轻微明度扰动,避免整片纯色看不出结构。 |
| 75 | +const PRESETS: [string[], number][] = [ |
| 76 | + [['grass', 'slime', 'emerald', 'lime', 'moss', 'leaves', 'vine', 'bamboo', 'cactus'], 0x4f9b49], |
| 77 | + [['water', 'ice', 'prismarine'], 0x4b83d1], |
| 78 | + [['lava', 'magma', 'fire'], 0xd76a25], |
| 79 | + [['sand', 'birch', 'end_stone', 'bone'], 0xd7c27d], |
| 80 | + [['oak', 'spruce', 'jungle', 'acacia', 'dark_oak', 'mangrove', 'cherry', 'planks', 'log', 'wood'], 0x8b5a34], |
| 81 | + [['deepslate', 'basalt', 'blackstone', 'obsidian', 'coal'], 0x3e434d], |
| 82 | + [['stone', 'cobblestone', 'andesite', 'diorite', 'granite', 'tuff', 'gravel'], 0x8b8f97], |
| 83 | + [['brick', 'terracotta', 'nether'], 0xb85f45], |
| 84 | + [['quartz', 'calcite', 'snow', 'white'], 0xe5e7eb], |
| 85 | + [['glass', 'lantern', 'glowstone', 'shroomlight'], 0xe2d6a8], |
| 86 | + [['copper'], 0xc27a46], |
| 87 | + [['gold'], 0xd9b646], |
| 88 | + [['diamond'], 0x52d4d8], |
| 89 | + [['amethyst', 'purpur'], 0x986bc7], |
| 90 | +] |
| 91 | +
|
| 92 | +function colorOf(name: string): THREE.Color { |
| 93 | + const key = name.toLowerCase() |
| 94 | + let base = 0x7f8a96 |
| 95 | + for (const [needles, color] of PRESETS) { |
| 96 | + if (needles.some((n) => key.includes(n))) { |
| 97 | + base = color |
| 98 | + break |
| 99 | + } |
| 100 | + } |
| 101 | + let hash = 0 |
| 102 | + for (let i = 0; i < key.length; i++) hash = (hash * 31 + key.charCodeAt(i)) >>> 0 |
| 103 | + const c = new THREE.Color(base) |
| 104 | + c.offsetHSL(0, 0, ((hash % 19) - 9) / 120) |
| 105 | + return c |
| 106 | +} |
| 107 | +
|
| 108 | +const TRANSPARENT = /(glass|ice|leaves|water|pane|vine)/i |
| 109 | +
|
| 110 | +// ─── 构建场景 ─── |
| 111 | +function build(m: Model) { |
| 112 | + if (!scene) return |
| 113 | + for (const mesh of meshes) { |
| 114 | + scene.remove(mesh) |
| 115 | + mesh.geometry.dispose() |
| 116 | + ;(mesh.material as THREE.Material).dispose() |
| 117 | + } |
| 118 | + meshes = [] |
| 119 | +
|
| 120 | + const byState = new Map<number, number[]>() |
| 121 | + for (let i = 0; i < m.count; i++) { |
| 122 | + const y = m.blocks[i * 4 + 1]! |
| 123 | + if (y > sliceY.value) continue |
| 124 | + const state = m.blocks[i * 4 + 3]! |
| 125 | + const list = byState.get(state) |
| 126 | + if (list) list.push(i) |
| 127 | + else byState.set(state, [i]) |
| 128 | + } |
| 129 | +
|
| 130 | + const [sx, sy, sz] = m.size |
| 131 | + const ox = -(sx - 1) / 2 |
| 132 | + const oy = -(sy - 1) / 2 |
| 133 | + const oz = -(sz - 1) / 2 |
| 134 | + const geometry = new THREE.BoxGeometry(1, 1, 1) |
| 135 | + const matrix = new THREE.Matrix4() |
| 136 | +
|
| 137 | + for (const [state, indices] of byState) { |
| 138 | + const name = m.palette[state] ?? 'unknown' |
| 139 | + const transparent = TRANSPARENT.test(name) |
| 140 | + const material = new THREE.MeshStandardMaterial({ |
| 141 | + color: colorOf(name), |
| 142 | + roughness: 0.95, |
| 143 | + metalness: 0.02, |
| 144 | + transparent, |
| 145 | + opacity: transparent ? 0.55 : 1, |
| 146 | + }) |
| 147 | +
|
| 148 | + const mesh = new THREE.InstancedMesh(geometry.clone(), material, indices.length) |
| 149 | + indices.forEach((idx, n) => { |
| 150 | + matrix.makeTranslation( |
| 151 | + m.blocks[idx * 4]! + ox, |
| 152 | + m.blocks[idx * 4 + 1]! + oy, |
| 153 | + m.blocks[idx * 4 + 2]! + oz, |
| 154 | + ) |
| 155 | + mesh.setMatrixAt(n, matrix) |
| 156 | + }) |
| 157 | + mesh.instanceMatrix.needsUpdate = true |
| 158 | + scene.add(mesh) |
| 159 | + meshes.push(mesh) |
| 160 | + } |
| 161 | +} |
| 162 | +
|
| 163 | +function computeStats(m: Model) { |
| 164 | + const counts = new Map<number, number>() |
| 165 | + for (let i = 0; i < m.count; i++) { |
| 166 | + const s = m.blocks[i * 4 + 3]! |
| 167 | + counts.set(s, (counts.get(s) ?? 0) + 1) |
| 168 | + } |
| 169 | + stats.value = [...counts.entries()] |
| 170 | + .map(([s, count]) => ({ name: m.palette[s] ?? 'unknown', count })) |
| 171 | + .sort((a, b) => b.count - a.count) |
| 172 | +} |
| 173 | +
|
| 174 | +async function init() { |
| 175 | + const res = await fetch(`/api/workshop/${props.workshopId}/preview`) |
| 176 | + if (!res.ok) { |
| 177 | + const body = (await res.json().catch(() => ({}))) as { error?: string } |
| 178 | + state.value = res.status === 404 ? 'empty' : 'error' |
| 179 | + message.value = body.error ?? '预览加载失败' |
| 180 | + return |
| 181 | + } |
| 182 | +
|
| 183 | + omitted.value = Number(res.headers.get('X-Model-Omitted') ?? 0) |
| 184 | + const decoded = decode(await res.arrayBuffer()) |
| 185 | + if (!decoded) { |
| 186 | + state.value = 'error' |
| 187 | + message.value = '预览数据格式不正确' |
| 188 | + return |
| 189 | + } |
| 190 | +
|
| 191 | + model = decoded |
| 192 | + maxY.value = decoded.size[1] |
| 193 | + sliceY.value = decoded.size[1] |
| 194 | + computeStats(decoded) |
| 195 | + state.value = 'ready' |
| 196 | +
|
| 197 | + await nextTick() |
| 198 | + if (!canvas.value) return |
| 199 | +
|
| 200 | + renderer = new THREE.WebGLRenderer({ canvas: canvas.value, antialias: true, alpha: true }) |
| 201 | + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) |
| 202 | +
|
| 203 | + scene = new THREE.Scene() |
| 204 | + const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 5000) |
| 205 | + const maxDim = Math.max(...decoded.size, 4) |
| 206 | + camera.position.set(maxDim * 1.2, maxDim * 1.1, maxDim * 1.4) |
| 207 | +
|
| 208 | + scene.add(new THREE.HemisphereLight(0xdbeafe, 0x0f172a, 1.2)) |
| 209 | + const key = new THREE.DirectionalLight(0xffffff, 1.35) |
| 210 | + key.position.set(1.5, 2.4, 1.8) |
| 211 | + scene.add(key) |
| 212 | +
|
| 213 | + controls = new OrbitControls(camera, canvas.value) |
| 214 | + controls.enableDamping = true |
| 215 | + controls.autoRotate = true |
| 216 | + controls.autoRotateSpeed = 0.6 |
| 217 | +
|
| 218 | + build(decoded) |
| 219 | +
|
| 220 | + const loop = () => { |
| 221 | + raf = requestAnimationFrame(loop) |
| 222 | + const el = canvas.value |
| 223 | + if (!el || !renderer || !scene) return |
| 224 | + const w = el.clientWidth || 1 |
| 225 | + const h = el.clientHeight || 1 |
| 226 | + if (el.width !== w || el.height !== h) { |
| 227 | + renderer.setSize(w, h, false) |
| 228 | + camera.aspect = w / h |
| 229 | + camera.updateProjectionMatrix() |
| 230 | + } |
| 231 | + controls?.update() |
| 232 | + renderer.render(scene, camera) |
| 233 | + } |
| 234 | + loop() |
| 235 | +} |
| 236 | +
|
| 237 | +watch(sliceY, () => { |
| 238 | + if (model) build(model) |
| 239 | +}) |
| 240 | +
|
| 241 | +onMounted(init) |
| 242 | +
|
| 243 | +onBeforeUnmount(() => { |
| 244 | + cancelAnimationFrame(raf) |
| 245 | + controls?.dispose() |
| 246 | + for (const mesh of meshes) { |
| 247 | + mesh.geometry.dispose() |
| 248 | + ;(mesh.material as THREE.Material).dispose() |
| 249 | + } |
| 250 | + renderer?.dispose() |
| 251 | +}) |
| 252 | +</script> |
| 253 | + |
| 254 | +<template> |
| 255 | + <div> |
| 256 | + <div |
| 257 | + class="relative h-96 overflow-hidden rounded-(--ui-radius) border border-(--ui-border) bg-(--ui-bg-elevated)" |
| 258 | + > |
| 259 | + <canvas v-show="state === 'ready'" ref="canvas" class="h-full w-full" /> |
| 260 | + |
| 261 | + <div |
| 262 | + v-if="state !== 'ready'" |
| 263 | + class="flex h-full items-center justify-center text-sm text-(--ui-text-dimmed)" |
| 264 | + > |
| 265 | + {{ |
| 266 | + state === 'loading' ? '加载预览…' : state === 'empty' ? '该作品没有可预览的结构文件' : message |
| 267 | + }} |
| 268 | + </div> |
| 269 | + </div> |
| 270 | + |
| 271 | + <template v-if="state === 'ready'"> |
| 272 | + <div class="mt-3 flex items-center gap-3 text-sm"> |
| 273 | + <label class="text-(--ui-text-muted)">层高</label> |
| 274 | + <input v-model.number="sliceY" type="range" :min="0" :max="maxY" class="flex-1" > |
| 275 | + <span class="w-16 text-right text-(--ui-text-dimmed)">{{ sliceY }} / {{ maxY }}</span> |
| 276 | + </div> |
| 277 | + |
| 278 | + <p v-if="omitted > 0" class="mt-2 text-xs text-(--ui-text-dimmed)"> |
| 279 | + 结构过大,已省略 {{ omitted }} 个方块未渲染。 |
| 280 | + </p> |
| 281 | + |
| 282 | + <details class="mt-3"> |
| 283 | + <summary class="cursor-pointer text-sm text-(--ui-text-muted)">用料统计</summary> |
| 284 | + <ul class="mt-2 grid grid-cols-2 gap-x-6 gap-y-1 text-sm sm:grid-cols-3"> |
| 285 | + <li v-for="s in stats" :key="s.name" class="flex justify-between gap-2"> |
| 286 | + <span class="truncate text-(--ui-text-muted)">{{ s.name }}</span> |
| 287 | + <span class="tabular-nums">{{ s.count }}</span> |
| 288 | + </li> |
| 289 | + </ul> |
| 290 | + </details> |
| 291 | + </template> |
| 292 | + </div> |
| 293 | +</template> |
0 commit comments