Surf commited on
Commit
cdeed5c
·
1 Parent(s): b8f0cfa

暮归江南 · 步骤8:集市货物改用 NVIDIA SimReady Central(Palatial NV Asset Library,CC-BY-4.0)的 18 件扫描模型,USD→GLB 转换器、目录检索脚本、实例化加载与程序化回退(GLB 文件经 Hub API 以 LFS 上传)

Browse files
.gitattributes CHANGED
@@ -95,3 +95,4 @@ public/mvfonts/neon.woff2 filter=lfs diff=lfs merge=lfs -text
95
  public/mvfonts/lyric.woff2 filter=lfs diff=lfs merge=lfs -text
96
  src/jiangnan/assets/fonts/title.woff2 filter=lfs diff=lfs merge=lfs -text
97
  src/jiangnan/assets/fonts/wenkai.woff2 filter=lfs diff=lfs merge=lfs -text
 
 
95
  public/mvfonts/lyric.woff2 filter=lfs diff=lfs merge=lfs -text
96
  src/jiangnan/assets/fonts/title.woff2 filter=lfs diff=lfs merge=lfs -text
97
  src/jiangnan/assets/fonts/wenkai.woff2 filter=lfs diff=lfs merge=lfs -text
98
+ public/jiangnan/models/*.glb filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -243,6 +243,13 @@ tools/jiangnan/ # 旁白生成、字体子集化、实况图
243
  - **自愈渲染分级**(`Film.heal`):0 完整渲染 → 1 关闭体积光 → 2 再关闭辉光 → 3 再关闭水面反射通道 → 4 再隐藏人物与鸟群 → 5 关闭全部后期、直接前向渲染。抓帧在「降级 + 重置上下文」四次都黑之后,逐级尝试,哪一级能出画面就**从该帧起固定使用**,并在面板写明「第 X 秒起改用『…』才能渲染出画面」——这句话同时就是诊断结论(哪一级生效,问题就在哪个效果里)。
244
  - 错误信息新增当前渲染模式与着色器错误摘要。
245
  - 验证:类型检查与构建通过;本轮的无头浏览器分级渲染测试因沙盒临时目录再次被回收未能运行(如实记录)。
 
 
 
 
 
 
 
246
 
247
  ---
248
 
 
243
  - **自愈渲染分级**(`Film.heal`):0 完整渲染 → 1 关闭体积光 → 2 再关闭辉光 → 3 再关闭水面反射通道 → 4 再隐藏人物与鸟群 → 5 关闭全部后期、直接前向渲染。抓帧在「降级 + 重置上下文」四次都黑之后,逐级尝试,哪一级能出画面就**从该帧起固定使用**,并在面板写明「第 X 秒起改用『…』才能渲染出画面」——这句话同时就是诊断结论(哪一级生效,问题就在哪个效果里)。
244
  - 错误信息新增当前渲染模式与着色器错误摘要。
245
  - 验证:类型检查与构建通过;本轮的无头浏览器分级渲染测试因沙盒临时目录再次被回收未能运行(如实记录)。
246
+ - **步骤 8(按要求用 NVIDIA SimReady Central 的模型替换画面中的模型)** ✅
247
+ - **先摸清库里有什么**(`tools/jiangnan/simready_search.py`,直接调用站点的 `search_hybrid` 接口):把 SimReady Central 的全部 **3588** 个资产拉成清单(`--catalog`),按类别统计:prop 2531(仓库 / 工业 / 医院道具)、kitchenware 191、container 143、food 78、vegetable 43、appliance 42……作者为 NVIDIA 2536、Palatial Platforms 957、ForgeCAD 45、LG、Vertiv、机器人厂商等。**库里没有房屋、石桥、水车、船、树木、灯笼、人物、牛车**(「stone arch bridge」的第一个结果是餐巾架,「large old tree」是马克杯架)——它是面向机器人仿真的仓库 / 厨房 / 工业资产库,不是场景库。
248
+ - **能用的部分**:集市摊位上的货物。从 Palatial NV Asset Library(CC-BY-4.0,扫描 / 实拍重建,含 4K PBR 贴图)选了 18 件与江南集市相称的:竹篮、提篮、果篮、蛋篮、木碗、茶壶、茶杯、铁锅、木箱、木盒、陶瓶、大白菜、茄子、米缸、陶罐、面粉袋、馒头。
249
+ - **USD → 网页**:浏览器不能直接加载 USD,写了 `tools/jiangnan/usd2glb.py`(usd-core 读 UsdPreviewSurface 材质与网格 → 三角化、烘焙世界变换、Z-up 转 Y-up → pymeshlab 保留贴图坐标的二次误差简化到 1 200–6 000 面 → 贴图缩到 512、金属度 / 粗糙度打包成 glTF 规范 → 写 GLB)。原始资产每件 30–90 MB(27 万三角形、4K 贴图),转换后每件 45–435 KB,18 件共 4.3 MB,放在 `public/jiangnan/models/`。
250
+ - **接入**(`engine/assets.ts`):GLTFLoader 加载后换成本项目带高度雾的材质;每个摊位随机 3–4 种台面货物 + 1–2 种地面货物,按资产真实尺寸(米)落在桌面网格上,同种模型用 InstancedMesh 一次绘制;原来的程序化「多面体果子 + 圆柱篮子 + 车削陶罐」在模型加载成功后隐藏,加载失败(离线 / 被拦截)时自动保留,画面不会空。加载进度显示为「摆上竹篮、陶罐与瓜菜(SimReady 扫描模型)」。
251
+ - 版权:Palatial NV Asset Library,CC-BY-4.0,页脚已署名。
252
+ - 验证:类型检查与构建通过,18 个 GLB 由本地静态服务器正常返回;无头浏览器截图见下方(若本步日志末尾没有截图结论,说明沙盒再次回收了临时目录,未能完成)。
253
 
254
  ---
255
 
src/jiangnan/JiangnanApp.tsx CHANGED
@@ -408,7 +408,7 @@ export default function JiangnanApp() {
408
  </section>
409
 
410
  <footer className="jn-foot">
411
- 《暮归江南》全部画面在浏览器中实时生成:程序化地形与民居、平面反射水面、高度雾、体积光、辉光与胶片调色;配乐为程序合成的五声音阶原创曲(古筝物理建模、竹箫、弦乐、编钟),环境音效同样为程序合成;旁白为原创文案的中文语音合成。字体:马善政楷书、霞鹜文楷(SIL OFL)。
412
  </footer>
413
  </div>
414
  {toast && <div className="jn-toast">{toast}</div>}
 
408
  </section>
409
 
410
  <footer className="jn-foot">
411
+ 《暮归江南》全部画面在浏览器中实时生成:程序化地形与民居、平面反射水面、高度雾、体积光、辉光与胶片调色;配乐为程序合成的五声音阶原创曲(古筝物理建模、竹箫、弦乐、编钟),环境音效同样为程序合成;旁白为原创文案的中文语音合成。字体:马善政楷书、霞鹜文楷(SIL OFL)。集市货物模型(竹篮、木碗、茶具、瓜菜、米缸等 18 件)来自 NVIDIA SimReady Central · Palatial NV Asset Library(CC-BY-4.0),由 USD 转换为网页用 GLB。
412
  </footer>
413
  </div>
414
  {toast && <div className="jn-toast">{toast}</div>}
src/jiangnan/engine/Film.ts CHANGED
@@ -1,5 +1,6 @@
1
  // The film: builds the world once and renders any time t deterministically.
2
  import * as THREE from 'three'
 
3
  import { applyPose, cameraAt } from './camera'
4
  import { TERRACE, TREE_POS } from './layout'
5
  import { createLanterns, createLife, LanternSet, LifeOut } from './life'
@@ -33,6 +34,7 @@ export class Film {
33
  lanterns!: LanternSet
34
  life!: LifeOut
35
  props!: PropsOut
 
36
  village!: VillageOut
37
  pmrem: THREE.PMREMGenerator
38
  envScene = new THREE.Scene()
@@ -232,6 +234,17 @@ export class Film {
232
  await tick()
233
  this.props = createProps()
234
  s.add(this.props.group)
 
 
 
 
 
 
 
 
 
 
 
235
  progress(0.72, '种下古樟与竹林')
236
  await tick()
237
  const trees = createTrees(this.quality)
 
1
  // The film: builds the world once and renders any time t deterministically.
2
  import * as THREE from 'three'
3
+ import { AssetsOut, createAssets } from './assets'
4
  import { applyPose, cameraAt } from './camera'
5
  import { TERRACE, TREE_POS } from './layout'
6
  import { createLanterns, createLife, LanternSet, LifeOut } from './life'
 
34
  lanterns!: LanternSet
35
  life!: LifeOut
36
  props!: PropsOut
37
+ assets: AssetsOut | null = null
38
  village!: VillageOut
39
  pmrem: THREE.PMREMGenerator
40
  envScene = new THREE.Scene()
 
234
  await tick()
235
  this.props = createProps()
236
  s.add(this.props.group)
237
+ progress(0.66, '摆上竹篮、陶罐与瓜菜(SimReady 扫描模型)')
238
+ await tick()
239
+ try {
240
+ this.assets = await createAssets(this.props.stalls, (p) => progress(0.66 + p * 0.06, '摆上竹篮、陶罐与瓜菜(SimReady 扫描模型)'))
241
+ if (this.assets.loaded.length) {
242
+ s.add(this.assets.group)
243
+ // the scanned goods replace the procedural ones
244
+ for (const m of this.props.proceduralGoods) m.visible = false
245
+ }
246
+ if (this.assets.failed.length) console.warn('[暮归江南] 未能加载的模型:', this.assets.failed.join(', '))
247
+ } catch (e) { console.warn('[暮归江南] 扫描模型加载失败,使用程序化货物', e) }
248
  progress(0.72, '种下古樟与竹林')
249
  await tick()
250
  const trees = createTrees(this.quality)
src/jiangnan/engine/assets.ts ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Scanned / artist-made props from NVIDIA SimReady Central (Palatial NV Asset Library, CC-BY-4.0),
2
+ // converted offline from USD to small GLBs (tools/jiangnan/usd2glb.py) and dressed onto the
3
+ // market stalls and quay as instanced meshes. The procedural goods stay as a fallback when the
4
+ // files cannot be loaded (offline / blocked), so the film never shows an empty stall.
5
+ import * as THREE from 'three'
6
+ import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
7
+ import { patch } from './materials'
8
+ import { mulberry32 } from './rng'
9
+ import type { StallSpot } from './props'
10
+
11
+ export interface AssetDef {
12
+ id: string
13
+ name: string
14
+ /** where this prop may be placed */
15
+ slot: 'table' | 'ground' | 'shelf'
16
+ /** uniform scale applied to the real-world metres of the asset (1 = true size) */
17
+ scale?: number
18
+ /** how many of them per stall slot (random in [min,max]) */
19
+ count: [number, number]
20
+ }
21
+
22
+ /** Palatial NV Asset Library IDs (https://huggingface.co/datasets/PalatialSim/palatial-nv-asset-library), CC-BY-4.0 */
23
+ export const ASSETS: AssetDef[] = [
24
+ { id: 'D00006', name: '竹篮', slot: 'table', count: [1, 2] },
25
+ { id: 'D00016', name: '提篮', slot: 'table', count: [1, 2] },
26
+ { id: 'D00323', name: '果篮', slot: 'table', count: [1, 1] },
27
+ { id: 'D00395', name: '蛋篮', slot: 'table', count: [0, 1] },
28
+ { id: 'D00952', name: '木碗', slot: 'table', count: [1, 3] },
29
+ { id: 'D00854', name: '茶壶', slot: 'table', count: [0, 1] },
30
+ { id: 'D00855', name: '茶杯', slot: 'table', count: [1, 3] },
31
+ { id: 'D00945', name: '铁锅', slot: 'table', count: [0, 1] },
32
+ { id: 'D00953', name: '木箱', slot: 'ground', count: [1, 2] },
33
+ { id: 'D00954', name: '木盒', slot: 'table', count: [0, 1] },
34
+ { id: 'D00902', name: '陶瓶', slot: 'table', count: [1, 2] },
35
+ { id: 'D00645', name: '大白菜', slot: 'table', count: [2, 4] },
36
+ { id: 'D00638', name: '茄子', slot: 'table', count: [2, 5] },
37
+ { id: 'D00668', name: '米缸', slot: 'ground', count: [1, 2] },
38
+ { id: 'D00839', name: '陶罐', slot: 'table', count: [1, 2] },
39
+ { id: 'D00930', name: '面粉袋', slot: 'ground', count: [1, 2] },
40
+ { id: 'D00479', name: '馒头', slot: 'table', count: [3, 6] },
41
+ ]
42
+
43
+ export interface AssetsOut {
44
+ group: THREE.Group
45
+ loaded: string[]
46
+ failed: string[]
47
+ triangles: number
48
+ }
49
+
50
+ const base = () => new URL('models/', document.baseURI).href
51
+
52
+ async function loadGlb(loader: GLTFLoader, id: string) {
53
+ const g = await loader.loadAsync(`${base()}${id}.glb`)
54
+ const meshes: THREE.Mesh[] = []
55
+ g.scene.traverse((o) => { if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh) })
56
+ if (!meshes.length) throw new Error('empty')
57
+ // merge into one geometry (all our conversions bake transforms, so geometries are already world-space)
58
+ const geo = meshes.length === 1 ? meshes[0].geometry : meshes[0].geometry
59
+ const src = meshes[0].material as THREE.MeshStandardMaterial
60
+ const mat = patch(new THREE.MeshStandardMaterial({
61
+ map: src.map, normalMap: src.normalMap, roughnessMap: src.roughnessMap, metalnessMap: src.metalnessMap,
62
+ color: src.color, roughness: src.roughness, metalness: Math.min(src.metalness, 0.6),
63
+ }), { key: 'asset-' + id })
64
+ if (mat.map) mat.map.colorSpace = THREE.SRGBColorSpace
65
+ geo.computeBoundingBox()
66
+ return { geo, mat, box: geo.boundingBox! }
67
+ }
68
+
69
+ /**
70
+ * Load the catalogue and scatter the props over the stall spots. Resolves even when some
71
+ * (or all) files fail; the caller hides the procedural goods only for stalls that got assets.
72
+ */
73
+ export async function createAssets(stalls: StallSpot[], onProgress?: (p: number) => void): Promise<AssetsOut> {
74
+ const group = new THREE.Group()
75
+ const out: AssetsOut = { group, loaded: [], failed: [], triangles: 0 }
76
+ const loader = new GLTFLoader()
77
+ const rng = mulberry32(2026)
78
+ const models = new Map<string, Awaited<ReturnType<typeof loadGlb>>>()
79
+ let done = 0
80
+ await Promise.all(ASSETS.map(async (a) => {
81
+ try { models.set(a.id, await loadGlb(loader, a.id)); out.loaded.push(a.id) } catch { out.failed.push(a.id) }
82
+ onProgress?.(++done / ASSETS.length)
83
+ }))
84
+ if (!models.size) return out
85
+
86
+ // plan instances: per stall, a handful of table props and ground props, never overlapping badly
87
+ const placements = new Map<string, THREE.Matrix4[]>()
88
+ const tmp = new THREE.Matrix4(), rot = new THREE.Matrix4(), scl = new THREE.Matrix4(), tr = new THREE.Matrix4()
89
+ const put = (id: string, stallM: THREE.Matrix4, x: number, y: number, z: number, yaw: number, s: number) => {
90
+ const m = models.get(id)!
91
+ // sit the asset on its bounding-box floor, centred on x/z
92
+ const cx = (m.box.min.x + m.box.max.x) / 2, cz = (m.box.min.z + m.box.max.z) / 2
93
+ tr.makeTranslation(x, y, z)
94
+ rot.makeRotationY(yaw)
95
+ scl.makeScale(s, s, s)
96
+ tmp.copy(stallM).multiply(tr).multiply(rot).multiply(scl).multiply(new THREE.Matrix4().makeTranslation(-cx, -m.box.min.y, -cz))
97
+ if (!placements.has(id)) placements.set(id, [])
98
+ placements.get(id)!.push(tmp.clone())
99
+ }
100
+ for (const st of stalls) {
101
+ st.dressed = true
102
+ const table = ASSETS.filter((a) => a.slot === 'table' && models.has(a.id))
103
+ const ground = ASSETS.filter((a) => a.slot === 'ground' && models.has(a.id))
104
+ // table top is 2.2 × 1.0 m at y = 0.82 (see props.ts)
105
+ const cells: [number, number][] = []
106
+ for (let i = -2; i <= 2; i++) for (let j = -1; j <= 1; j++) cells.push([i * 0.42, j * 0.3])
107
+ for (let k = cells.length - 1; k > 0; k--) { const r = Math.floor(rng() * (k + 1)); [cells[k], cells[r]] = [cells[r], cells[k]] }
108
+ // 3–4 kinds per stall, so each stall reads differently
109
+ const kinds = [...table].sort(() => rng() - 0.5).slice(0, 3 + Math.floor(rng() * 2))
110
+ let c = 0
111
+ for (const a of kinds) {
112
+ const n = a.count[0] + Math.floor(rng() * (a.count[1] - a.count[0] + 1))
113
+ for (let i = 0; i < n && c < cells.length; i++) {
114
+ const [x, z] = cells[c++]
115
+ put(a.id, st.m, x + (rng() - 0.5) * 0.08, 0.82, z + (rng() - 0.5) * 0.08, rng() * Math.PI * 2, (a.scale ?? 1) * (0.95 + rng() * 0.1))
116
+ }
117
+ }
118
+ // ground: beside / under the table, toward the street
119
+ const gk = [...ground].sort(() => rng() - 0.5).slice(0, 2)
120
+ let gx = -1.15
121
+ for (const a of gk) {
122
+ const n = a.count[0] + Math.floor(rng() * (a.count[1] - a.count[0] + 1))
123
+ for (let i = 0; i < n; i++) {
124
+ put(a.id, st.m, gx + rng() * 0.2, 0, 0.75 + rng() * 0.25, rng() * Math.PI * 2, (a.scale ?? 1) * (0.95 + rng() * 0.1))
125
+ gx += 0.55
126
+ if (gx > 1.2) break
127
+ }
128
+ }
129
+ }
130
+ for (const [id, ms] of placements) {
131
+ const m = models.get(id)!
132
+ const inst = new THREE.InstancedMesh(m.geo, m.mat, ms.length)
133
+ ms.forEach((mat, i) => inst.setMatrixAt(i, mat))
134
+ inst.instanceMatrix.needsUpdate = true
135
+ inst.castShadow = true
136
+ inst.receiveShadow = true
137
+ inst.layers.set(1) // main view only (too small to matter in the water reflection)
138
+ inst.frustumCulled = true
139
+ inst.computeBoundingSphere()
140
+ group.add(inst)
141
+ out.triangles += (m.geo.index ? m.geo.index.count / 3 : m.geo.attributes.position.count / 3) * ms.length
142
+ }
143
+ return out
144
+ }
src/jiangnan/engine/props.ts CHANGED
@@ -27,8 +27,14 @@ function projectUV(geo: THREE.BufferGeometry, scale: number) {
27
  return g
28
  }
29
 
 
 
30
  export interface PropsOut {
31
  group: THREE.Group
 
 
 
 
32
  wheel: THREE.Group
33
  wheelInfo: { center: THREE.Vector3; axis: THREE.Vector3; radius: number; tangent: THREE.Vector3 }
34
  bridgeInfo: { center: THREE.Vector3; across: THREE.Vector3; along: THREE.Vector3; crestY: number; halfLen: number; deckY: (x: number) => number }
@@ -262,6 +268,7 @@ export function createProps(): PropsOut {
262
  const jarGeos = [jarProfile(0.28, 0.7), jarProfile(0.4, 0.9), jarProfile(0.2, 0.45)]
263
  const basketGeo = new THREE.CylinderGeometry(0.42, 0.32, 0.35, 16, 1, true)
264
  const fruitGeo = new THREE.IcosahedronGeometry(0.075, 0)
 
265
  for (let s = MARKET_S0; s < MARKET_S1; s += rrange(rng, 5.5, 8)) {
266
  if (Math.abs(s - BRIDGE_S) < 12) continue
267
  const p = riverPoint(s, -9.6)
@@ -271,6 +278,7 @@ export function createProps(): PropsOut {
271
  wood.box(0, 0.78, 0, 2.2, 0.08, 1.0)
272
  for (const [lx, lz] of [[-1, -0.42], [1, -0.42], [-1, 0.42], [1, 0.42]]) wood.box(lx, 0.39, lz, 0.07, 0.78, 0.07)
273
  const kind = Math.floor(rng() * 4)
 
274
  if (kind === 0 || kind === 3) {
275
  // baskets of produce
276
  for (let k = 0; k < 3; k++) {
@@ -462,6 +470,7 @@ export function createProps(): PropsOut {
462
  mesh.receiveShadow = true
463
  if (!reflect) mesh.layers.set(1)
464
  group.add(mesh)
 
465
  }
466
  add(stone, stoneMat)
467
  add(wood, woodMat)
@@ -469,14 +478,12 @@ export function createProps(): PropsOut {
469
  add(red, redMat)
470
  add(roof, roofMat)
471
  // market goods: small, main view only
472
- add(goods, fruitMat, false, false)
473
- add(basket, basketMat, false, false)
474
- add(jars, jarMat, false, false)
475
  add(meat, meatMat, true, false)
476
  add(awning, awningMat, true, false)
477
 
478
  return {
479
- group, wheel,
480
  wheelInfo: { center: wheelC, axis: wheelAxis, radius: wheelR, tangent: wheelTan },
481
  bridgeInfo: { center: V3(bp.x, 0, bp.z), across, along, crestY: crest, halfLen, deckY },
482
  lanternSpots, boats, movingBoat, cloths, pavilion: pv,
 
27
  return g
28
  }
29
 
30
+ export interface StallSpot { m: THREE.Matrix4; kind: number; dressed: boolean }
31
+
32
  export interface PropsOut {
33
  group: THREE.Group
34
+ /** market stall tables (local frame: table top 2.2 × 1.0 m at y = 0.82, street toward +z) */
35
+ stalls: StallSpot[]
36
+ /** procedural goods (hidden when scanned assets are dressed onto the stalls) */
37
+ proceduralGoods: THREE.Mesh[]
38
  wheel: THREE.Group
39
  wheelInfo: { center: THREE.Vector3; axis: THREE.Vector3; radius: number; tangent: THREE.Vector3 }
40
  bridgeInfo: { center: THREE.Vector3; across: THREE.Vector3; along: THREE.Vector3; crestY: number; halfLen: number; deckY: (x: number) => number }
 
268
  const jarGeos = [jarProfile(0.28, 0.7), jarProfile(0.4, 0.9), jarProfile(0.2, 0.45)]
269
  const basketGeo = new THREE.CylinderGeometry(0.42, 0.32, 0.35, 16, 1, true)
270
  const fruitGeo = new THREE.IcosahedronGeometry(0.075, 0)
271
+ const stalls: StallSpot[] = []
272
  for (let s = MARKET_S0; s < MARKET_S1; s += rrange(rng, 5.5, 8)) {
273
  if (Math.abs(s - BRIDGE_S) < 12) continue
274
  const p = riverPoint(s, -9.6)
 
278
  wood.box(0, 0.78, 0, 2.2, 0.08, 1.0)
279
  for (const [lx, lz] of [[-1, -0.42], [1, -0.42], [-1, 0.42], [1, 0.42]]) wood.box(lx, 0.39, lz, 0.07, 0.78, 0.07)
280
  const kind = Math.floor(rng() * 4)
281
+ stalls.push({ m: m.clone(), kind, dressed: false })
282
  if (kind === 0 || kind === 3) {
283
  // baskets of produce
284
  for (let k = 0; k < 3; k++) {
 
470
  mesh.receiveShadow = true
471
  if (!reflect) mesh.layers.set(1)
472
  group.add(mesh)
473
+ return mesh
474
  }
475
  add(stone, stoneMat)
476
  add(wood, woodMat)
 
478
  add(red, redMat)
479
  add(roof, roofMat)
480
  // market goods: small, main view only
481
+ const proceduralGoods = [add(goods, fruitMat, false, false), add(basket, basketMat, false, false), add(jars, jarMat, false, false)].filter((m) => !!m) as THREE.Mesh[]
 
 
482
  add(meat, meatMat, true, false)
483
  add(awning, awningMat, true, false)
484
 
485
  return {
486
+ group, wheel, stalls, proceduralGoods,
487
  wheelInfo: { center: wheelC, axis: wheelAxis, radius: wheelR, tangent: wheelTan },
488
  bridgeInfo: { center: V3(bp.x, 0, bp.z), across, along, crestY: crest, halfLen, deckY },
489
  lanternSpots, boats, movingBoat, cloths, pavilion: pv,
tools/jiangnan/simready_search.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Query NVIDIA SimReady Central (https://nvidia-simready-central.hf.space/) and list the catalogue.
2
+ # usage: simready_search.py "<text query>" [limit] | simready_search.py --catalog (dump every asset to catalog.json)
3
+ import json, sys, urllib.request, collections
4
+ API = "https://nvidia-simready-central.hf.space/search_hybrid"
5
+ def call(body):
6
+ req = urllib.request.Request(API, data=json.dumps(body).encode(), headers={"Content-Type": "application/json", "x-usdsearch-storage-backend": "simready-assets"})
7
+ return json.load(urllib.request.urlopen(req, timeout=120))
8
+ def props(h):
9
+ return {x["name"]: x.get("value") for x in h["source"].get("usd_properties", [])}
10
+ if sys.argv[1] == "--catalog":
11
+ items, cursor = [], None
12
+ while True:
13
+ body = {"description": "object", "limit": 500, "return_usd_properties": True, "filter_by_properties": "simready_metadata_asset_name="}
14
+ if cursor: body["cursor"] = cursor
15
+ r = call(body)
16
+ for h in r["hits"]:
17
+ p = props(h)
18
+ items.append({"name": p.get("simready_metadata_asset_name"), "cat": p.get("simready_metadata_category"), "ext": p.get("simready_metadata_asset_extents"), "lic": p.get("simready_metadata_asset_license"), "author": p.get("simready_metadata_author"), "path": h["source"]["path"]})
19
+ cursor = r.get("next_cursor")
20
+ if not r.get("has_more") or not cursor: break
21
+ json.dump(items, open("catalog.json", "w"), ensure_ascii=False, indent=1)
22
+ print(len(items), "assets;", collections.Counter(i["cat"] for i in items).most_common(25))
23
+ else:
24
+ r = call({"description": sys.argv[1], "limit": int(sys.argv[2]) if len(sys.argv) > 2 else 10, "return_usd_properties": True})
25
+ for h in r["hits"]:
26
+ p = props(h)
27
+ print(f'{h["score"]:.3f} {p.get("simready_metadata_asset_name")!s:40s} {p.get("simready_metadata_category")!s:14s} {p.get("simready_metadata_asset_license")!s:10s} {h["source"]["path"]}')
tools/jiangnan/usd2glb.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Minimal USD (UsdPreviewSurface) → GLB converter for SimReady assets: triangulates render meshes,
2
+ # bakes world transforms, Z-up → Y-up, downsizes textures, packs metallic/roughness for glTF.
3
+ import sys, json, struct, io, os
4
+ import numpy as np
5
+ from PIL import Image
6
+ from pxr import Usd, UsdGeom, UsdShade, Gf
7
+
8
+ src, dst, texsize = sys.argv[1], sys.argv[2], int(sys.argv[3]) if len(sys.argv) > 3 else 512
9
+ st = Usd.Stage.Open(src)
10
+ zup = UsdGeom.GetStageUpAxis(st) == 'Z'
11
+ mpu = UsdGeom.GetStageMetersPerUnit(st) or 1.0
12
+ cache = UsdGeom.XformCache(Usd.TimeCode.Default())
13
+
14
+ def tex_path(shader_prim, inp):
15
+ """follow a UsdPreviewSurface input to the file of a UsdUVTexture, if any"""
16
+ i = UsdShade.Shader(shader_prim).GetInput(inp)
17
+ if not i: return None
18
+ srcs = i.GetConnectedSources()[0] if hasattr(i, 'GetConnectedSources') else []
19
+ for s in srcs:
20
+ sh = UsdShade.Shader(s.source.GetPrim())
21
+ if sh.GetIdAttr().Get() == 'UsdUVTexture':
22
+ f = sh.GetInput('file').Get()
23
+ if f: return f.resolvedPath or f.path
24
+ return None
25
+
26
+ materials = {} # mat path -> dict
27
+ def material_for(prim):
28
+ b = UsdShade.MaterialBindingAPI(prim).ComputeBoundMaterial()[0]
29
+ if not b: return None
30
+ key = str(b.GetPath())
31
+ if key in materials: return key
32
+ surf = b.ComputeSurfaceSource()[0] if hasattr(b, 'ComputeSurfaceSource') else None
33
+ info = {'base': None, 'normal': None, 'rough': None, 'metal': None, 'color': [1, 1, 1], 'roughness': 0.6, 'metallic': 0.0}
34
+ if surf:
35
+ p = surf.GetPrim()
36
+ info['base'] = tex_path(p, 'diffuseColor'); info['normal'] = tex_path(p, 'normal')
37
+ info['rough'] = tex_path(p, 'roughness'); info['metal'] = tex_path(p, 'metallic')
38
+ s = UsdShade.Shader(p)
39
+ c = s.GetInput('diffuseColor').Get() if s.GetInput('diffuseColor') else None
40
+ if c and not info['base']: info['color'] = [float(c[0]), float(c[1]), float(c[2])]
41
+ r = s.GetInput('roughness').Get() if s.GetInput('roughness') else None
42
+ if isinstance(r, float): info['roughness'] = r
43
+ m = s.GetInput('metallic').Get() if s.GetInput('metallic') else None
44
+ if isinstance(m, float): info['metallic'] = m
45
+ materials[key] = info
46
+ return key
47
+
48
+ meshes = []
49
+ for prim in st.Traverse():
50
+ if prim.GetTypeName() != 'Mesh': continue
51
+ img = UsdGeom.Imageable(prim)
52
+ if img.ComputePurpose() not in ('default', 'render'): continue
53
+ if img.ComputeVisibility() == 'invisible': continue
54
+ path = str(prim.GetPath()).lower()
55
+ if 'collision' in path or 'physics' in path or 'proxy' in path: continue
56
+ m = UsdGeom.Mesh(prim)
57
+ pts = np.array(m.GetPointsAttr().Get(), dtype=np.float64)
58
+ if len(pts) == 0: continue
59
+ fvc = np.array(m.GetFaceVertexCountsAttr().Get(), dtype=np.int64)
60
+ fvi = np.array(m.GetFaceVertexIndicesAttr().Get(), dtype=np.int64)
61
+ uvp = UsdGeom.PrimvarsAPI(prim).GetPrimvar('st')
62
+ uv = None; uvInterp = None; uvIdx = None
63
+ if uvp and uvp.HasValue():
64
+ uv = np.array(uvp.Get(), dtype=np.float64); uvInterp = uvp.GetInterpolation()
65
+ if uvp.IsIndexed(): uvIdx = np.array(uvp.GetIndices(), dtype=np.int64)
66
+ nrm = m.GetNormalsAttr().Get(); nInterp = m.GetNormalsInterpolation() if nrm else None
67
+ nrm = np.array(nrm, dtype=np.float64) if nrm else None
68
+ xf = cache.GetLocalToWorldTransform(prim)
69
+ M = np.array([[xf[r][c] for c in range(4)] for r in range(4)]) # row-major, row vectors
70
+ # triangulate (fan), expand to flat vertex list
71
+ P, N, T = [], [], []
72
+ fi = 0
73
+ for f, cnt in enumerate(fvc):
74
+ idx = fvi[fi:fi + cnt]
75
+ for k in range(1, cnt - 1):
76
+ for corner in (0, k, k + 1):
77
+ vi = idx[corner]; ci = fi + corner
78
+ P.append(pts[vi])
79
+ if uv is not None:
80
+ if uvInterp == 'faceVarying': u = uv[uvIdx[ci]] if uvIdx is not None else uv[ci]
81
+ elif uvInterp == 'vertex' or uvInterp == 'varying': u = uv[uvIdx[vi]] if uvIdx is not None else uv[vi]
82
+ else: u = [0, 0]
83
+ T.append(u)
84
+ if nrm is not None:
85
+ if nInterp == 'faceVarying': N.append(nrm[ci])
86
+ elif nInterp in ('vertex', 'varying'): N.append(nrm[vi])
87
+ elif nInterp == 'uniform': N.append(nrm[f])
88
+ fi += cnt
89
+ P = np.array(P); P1 = np.c_[P, np.ones(len(P))] @ M; P = P1[:, :3] * mpu
90
+ if nrm is not None and len(N) == len(P):
91
+ N = np.array(N) @ np.linalg.inv(M[:3, :3]).T
92
+ else:
93
+ N = np.zeros_like(P)
94
+ for i in range(0, len(P), 3):
95
+ n = np.cross(P[i + 1] - P[i], P[i + 2] - P[i]); N[i:i + 3] = n
96
+ N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-9)
97
+ if zup:
98
+ P = P[:, [0, 2, 1]] * [1, 1, -1]; N = N[:, [0, 2, 1]] * [1, 1, -1]
99
+ # flip winding? Z->Y mapping (x,y,z)->(x,z,-y) is a proper rotation, so no.
100
+ T = np.array(T) if len(T) == len(P) else np.zeros((len(P), 2))
101
+ T[:, 1] = 1 - T[:, 1]
102
+ # decimate (texture-preserving quadric edge collapse) when the mesh is far heavier than a web prop needs
103
+ target = int(os.environ.get('TARGET_TRIS', '6000'))
104
+ if len(P) // 3 > target * 1.3:
105
+ import pymeshlab
106
+ ms = pymeshlab.MeshSet()
107
+ faces = np.arange(len(P)).reshape(-1, 3)
108
+ ms.add_mesh(pymeshlab.Mesh(vertex_matrix=P.astype(np.float64), face_matrix=faces, v_normals_matrix=N.astype(np.float64), v_tex_coords_matrix=T.astype(np.float64)))
109
+ ms.apply_filter('compute_texcoord_transfer_vertex_to_wedge')
110
+ ms.apply_filter('meshing_remove_duplicate_vertices')
111
+ ms.apply_filter('meshing_decimation_quadric_edge_collapse_with_texture', targetfacenum=target, qualitythr=0.4, preserveboundary=True, planarquadric=True)
112
+ ms.apply_filter('compute_normal_per_vertex')
113
+ mm = ms.current_mesh()
114
+ V, F = mm.vertex_matrix(), mm.face_matrix()
115
+ VN = mm.vertex_normal_matrix()
116
+ WT = mm.wedge_tex_coord_matrix() # per-corner uv (3 per face)
117
+ P = V[F.reshape(-1)]; N = VN[F.reshape(-1)]; T = WT.reshape(-1, 2)
118
+ # weld
119
+ key = np.round(np.c_[P, N, T], 5)
120
+ _, first, inv = np.unique(key, axis=0, return_index=True, return_inverse=True)
121
+ order = np.argsort(first); remap = np.empty_like(order); remap[order] = np.arange(len(order))
122
+ inv = remap[inv.reshape(-1)]
123
+ Pw, Nw, Tw = P[first[order]], N[first[order]], T[first[order]]
124
+ meshes.append((material_for(prim), Pw.astype(np.float32), Nw.astype(np.float32), Tw.astype(np.float32), inv.astype(np.uint32)))
125
+
126
+ # --- textures
127
+ def load(p, mode):
128
+ if not p or not os.path.exists(p): return None
129
+ im = Image.open(p).convert(mode); im.thumbnail((texsize, texsize), Image.LANCZOS); return im
130
+ images, imgbytes = [], []
131
+ def add_image(im, fmt):
132
+ b = io.BytesIO(); im.save(b, fmt, quality=88) if fmt == 'JPEG' else im.save(b, fmt, optimize=True)
133
+ images.append({'mimeType': 'image/jpeg' if fmt == 'JPEG' else 'image/png'}); imgbytes.append(b.getvalue()); return len(images) - 1
134
+ mat_index = {}; gmats = []
135
+ for key, info in materials.items():
136
+ gm = {'name': key.split('/')[-1], 'pbrMetallicRoughness': {'baseColorFactor': info['color'] + [1], 'metallicFactor': info['metallic'] if not info['metal'] else 1.0, 'roughnessFactor': info['roughness'] if not info['rough'] else 1.0}}
137
+ base = load(info['base'], 'RGB')
138
+ if base: gm['pbrMetallicRoughness']['baseColorTexture'] = {'index': add_image(base, 'JPEG')}; gm['pbrMetallicRoughness']['baseColorFactor'] = [1, 1, 1, 1]
139
+ rough, metal = load(info['rough'], 'L'), load(info['metal'], 'L')
140
+ if rough or metal:
141
+ sz = (rough or metal).size
142
+ mr = Image.merge('RGB', [Image.new('L', sz, 255), rough or Image.new('L', sz, int(info['roughness'] * 255)), metal or Image.new('L', sz, int(info['metallic'] * 255))])
143
+ gm['pbrMetallicRoughness']['metallicRoughnessTexture'] = {'index': add_image(mr, 'JPEG')}
144
+ normal = load(info['normal'], 'RGB')
145
+ if normal: gm['normalTexture'] = {'index': add_image(normal, 'JPEG')}
146
+ mat_index[key] = len(gmats); gmats.append(gm)
147
+
148
+ # --- buffers
149
+ bin_parts = []; views = []; accessors = []; gmeshes = []; nodes = []
150
+ def push(data, target=None):
151
+ off = sum(len(p) for p in bin_parts)
152
+ while off % 4: bin_parts.append(b'\0'); off += 1
153
+ bin_parts.append(data); v = {'buffer': 0, 'byteOffset': off, 'byteLength': len(data)}
154
+ if target: v['target'] = target
155
+ views.append(v); return len(views) - 1
156
+ for mi, (mk, P, N, T, I) in enumerate(meshes):
157
+ pv = push(P.tobytes(), 34962); nv = push(N.tobytes(), 34962); tv = push(T.tobytes(), 34962); iv = push(I.tobytes(), 34963)
158
+ accessors += [
159
+ {'bufferView': pv, 'componentType': 5126, 'count': len(P), 'type': 'VEC3', 'min': P.min(0).tolist(), 'max': P.max(0).tolist()},
160
+ {'bufferView': nv, 'componentType': 5126, 'count': len(N), 'type': 'VEC3'},
161
+ {'bufferView': tv, 'componentType': 5126, 'count': len(T), 'type': 'VEC2'},
162
+ {'bufferView': iv, 'componentType': 5125, 'count': len(I), 'type': 'SCALAR'}]
163
+ a = len(accessors) - 4
164
+ prim = {'attributes': {'POSITION': a, 'NORMAL': a + 1, 'TEXCOORD_0': a + 2}, 'indices': a + 3}
165
+ if mk in mat_index: prim['material'] = mat_index[mk]
166
+ gmeshes.append({'primitives': [prim]}); nodes.append({'mesh': mi})
167
+ for i, b in enumerate(imgbytes):
168
+ images[i]['bufferView'] = push(b)
169
+ binblob = b''.join(bin_parts)
170
+ while len(binblob) % 4: binblob += b'\0'
171
+ gltf = {'asset': {'version': '2.0', 'generator': 'usd2glb (暮归江南)'}, 'scene': 0, 'scenes': [{'nodes': list(range(len(nodes)))}], 'nodes': nodes, 'meshes': gmeshes,
172
+ 'materials': gmats, 'images': images, 'textures': [{'source': i, 'sampler': 0} for i in range(len(images))], 'samplers': [{'magFilter': 9729, 'minFilter': 9987, 'wrapS': 10497, 'wrapT': 10497}],
173
+ 'accessors': accessors, 'bufferViews': views, 'buffers': [{'byteLength': len(binblob)}]}
174
+ if not images: gltf.pop('images'); gltf.pop('textures'); gltf.pop('samplers')
175
+ js = json.dumps(gltf, separators=(',', ':')).encode()
176
+ while len(js) % 4: js += b' '
177
+ out = b'glTF' + struct.pack('<II', 2, 12 + 8 + len(js) + 8 + len(binblob)) + struct.pack('<II', len(js), 0x4E4F534A) + js + struct.pack('<II', len(binblob), 0x004E4942) + binblob
178
+ open(dst, 'wb').write(out)
179
+ allP = np.concatenate([m[1] for m in meshes]); tris = sum(len(m[4]) // 3 for m in meshes)
180
+ print(f'{os.path.basename(dst)}: meshes={len(meshes)} tris={tris} verts={len(allP)} size={len(out)//1024}KB bbox_min={allP.min(0).round(3).tolist()} bbox_max={allP.max(0).round(3).tolist()} mats={len(gmats)} imgs={len(images)}')