Skip to content

Commit d3e9b70

Browse files
Lokins577claude
andcommitted
feat: NBT 3D 预览
H7 主体。原始 .nbt 不出站,客户端只拿到解析后的方块数据: - NBT 解析器移植自旧实现,保留数组长度与嵌套深度上限防恶意文件打爆 Worker - 渲染模型编码为紧凑二进制(每方块 8 字节),8x8x8 测试结构 2.4KB, 同内容 JSON 约 20KB - 空气类方块在服务端剔除,超上限时如实上报省略数量而非静默截断 - 已发布作品的预览进边缘缓存,未发布的不缓存 - 客户端 three.js + OrbitControls,Y 轴切片查看内部,用料统计 - 图标集本地打包,不再依赖 iconify 远程 API Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ad188fc commit d3e9b70

11 files changed

Lines changed: 756 additions & 14 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@ data/
4343
# 构建期生成的 Minecraft 资源
4444
public/vendor/mc/
4545
app/public/vendor/mc/
46+
.playwright-mcp/
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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>

app/pages/workshop/[id].vue

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,9 @@ useHead({ title: () => `${data.value?.item.title ?? '作品'} · 创意工坊` }
2222

2323
<p class="mt-6 whitespace-pre-line">{{ data.item.description }}</p>
2424

25-
<!-- 3D 预览待 H7 实现:服务端解析 NBT 返回紧凑渲染数据,原始文件不出站 -->
26-
<div
27-
class="mt-8 flex h-64 items-center justify-center rounded-(--ui-radius) border border-dashed border-(--ui-border) text-sm text-(--ui-text-dimmed)"
28-
>
29-
3D 预览开发中
25+
<!-- 服务端解析 NBT 返回紧凑二进制,原始文件不出站 -->
26+
<div class="mt-8">
27+
<NbtViewer :workshop-id="data.item.id" />
3028
</div>
3129

3230
<section v-if="data.item.files.length" class="mt-8">

nuxt.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ export default defineNuxtConfig({
1212

1313
css: ['~/assets/css/main.css'],
1414

15+
// 图标本地打包,不走 iconify 的远程 API:
16+
// 线上多一次外部请求既慢又多一个依赖点,网络不畅时图标直接不显示。
17+
icon: {
18+
provider: 'iconify',
19+
serverBundle: { collections: ['lucide'] },
20+
},
21+
1522
devtools: { enabled: true },
1623

1724
nitro: {

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@nuxt/ui": "^4.10.0",
2727
"hono": "^4.12.34",
2828
"nuxt": "^4.5.1",
29+
"three": "^0.185.1",
2930
"vue": "^3.5.40",
3031
"vue-router": "^5.2.0"
3132
},
@@ -39,7 +40,9 @@
3940
},
4041
"devDependencies": {
4142
"@cloudflare/workers-types": "^5.20260801.1",
43+
"@iconify-json/lucide": "^1.2.121",
4244
"@nuxt/eslint": "^1.16.0",
45+
"@types/three": "^0.185.3",
4346
"eslint": "^10.8.0",
4447
"nitro-cloudflare-dev": "^0.2.2",
4548
"prettier": "^3.9.6",

0 commit comments

Comments
 (0)