1
0
mirror of synced 2026-11-01 12:46:57 +08:00
Files
cook/docs/scripts/export-cloud-kitchen.ts

159 lines
7.6 KiB
TypeScript

import { execFile } from 'node:child_process'
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import * as THREE from 'three'
import { USDZExporter } from 'three/addons/exporters/USDZExporter.js'
import { ingredientCatalog } from '../.vitepress/theme/cloud-kitchen/ingredient-catalog'
import { createIngredientLibrary } from '../.vitepress/theme/cloud-kitchen/ingredients'
import { createKitchenObjects } from '../.vitepress/theme/cloud-kitchen/objects'
// The website remains the modeling source. Export the exact same geometry for
// RealityKit, retaining named interaction nodes rather than flattening the room.
const kitchen = createKitchenObjects()
const exportedGeometry = new Set<THREE.BufferGeometry>()
const exportedMaterials = new Set<THREE.Material>()
const run = promisify(execFile)
const temporary = await mkdtemp(join(tmpdir(), 'cook-usd-export-'))
const directory = new URL('../../apps/ios/Cook/Resources/', import.meta.url)
const publicDirectory = new URL('../public/models/ingredients/', import.meta.url)
const foodLibrary = createIngredientLibrary()
async function packageModel(name: string, root: THREE.Object3D) {
const raw = await new USDZExporter().parseAsync(root, { onlyVisible: false, includeAnchoringProperties: false })
const source = join(temporary, `${name}-source.usdz`)
const binary = join(temporary, `${name}.usdc`)
const packed = join(temporary, `${name}.usdz`)
await writeFile(source, raw)
// Keep the named hierarchy; flatten references into one standard binary layer.
await run('usdcat', [source, '--flatten', '--skipSourceFileComment', '-o', binary])
await run('usdzip', [packed, '--asset', binary])
return { packed, bytes: await readFile(packed), sourceBytes: raw.byteLength }
}
function bakeNormals(root: THREE.Object3D) {
root.traverse((object) => {
if (!(object instanceof THREE.Mesh))
return
const geometry = object.geometry.index ? object.geometry.toNonIndexed() : object.geometry.clone()
geometry.computeVertexNormals()
exportedGeometry.add(geometry)
object.geometry = geometry
})
}
// Projected convex hulls let the native perspective camera fit the actual room,
// rather than a much larger axis-aligned box around the diagonal kitchen.
function hull(points: [number, number][]) {
points.sort((a, b) => a[0] - b[0] || a[1] - b[1])
const turn = (a: number[], b: number[], c: number[]) => (b[0]! - a[0]!) * (c[1]! - a[1]!) - (b[1]! - a[1]!) * (c[0]! - a[0]!)
const half = (input: [number, number][]) => {
const result: [number, number][] = []
for (const point of input) {
while (result.length > 1 && turn(result.at(-2)!, result.at(-1)!, point) <= 0)
result.pop()
result.push(point)
}
return result.slice(0, -1)
}
return [...half(points), ...half([...points].reverse())].map(point => point.map(value => Number(value.toFixed(6))))
}
try {
kitchen.root.name = 'CloudKitchen'
kitchen.clouds.name = 'CloudBase'
kitchen.pot.name = 'CookPot'
kitchen.potFood.name = 'PotFood'
kitchen.flame.name = 'StoveFlame'
kitchen.flights.name = 'FoodFlights'
kitchen.steam.forEach((puff, index) => puff.name = `Steam_${index}`)
const ingredients = kitchen.ingredients.map((ingredient, index) => {
const id = ingredientCatalog[index]!.id
ingredient.group.name = `Ingredient_${id}`
ingredient.ring.name = `Selection_${id}`
const previous = ingredient.ring.material as THREE.MeshBasicMaterial
const material = new THREE.MeshStandardMaterial({ color: previous.color, roughness: 0.8 })
exportedMaterials.add(material)
ingredient.ring.material = material
return { id, name: ingredient.name, node: ingredient.group.name, icon: `KitchenIngredient_${id}` }
})
let meshCount = 0
let triangles = 0
kitchen.root.traverse((object) => {
if (!(object instanceof THREE.Mesh))
return
const geometry = object.geometry.index ? object.geometry.toNonIndexed() : object.geometry.clone()
// Flat shading is a renderer flag in Three; bake its normals for native USD.
geometry.computeVertexNormals()
exportedGeometry.add(geometry)
object.geometry = geometry
meshCount++
triangles += geometry.getAttribute('position').count / 3
})
kitchen.root.updateMatrixWorld(true)
const center = new THREE.Box3().setFromObject(kitchen.root).getCenter(new THREE.Vector3())
const direction = new THREE.Vector3(8.6, 5.65, 10.7).normalize()
const right = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), direction).normalize()
const up = new THREE.Vector3().crossVectors(direction, right)
const horizontal: [number, number][] = []
const vertical: [number, number][] = []
const point = new THREE.Vector3()
kitchen.root.traverse((object) => {
if (!(object instanceof THREE.Mesh))
return
const positions = object.geometry.getAttribute('position')
for (let i = 0; i < positions.count; i++) {
point.fromBufferAttribute(positions, i).applyMatrix4(object.matrixWorld).sub(center)
horizontal.push([point.dot(right), point.dot(direction)])
vertical.push([point.dot(up), point.dot(direction)])
}
})
const model = await packageModel('CloudKitchen', kitchen.root)
const manifest = {
version: 1,
model: 'CloudKitchen',
ingredients,
camera: { center: center.toArray(), direction: direction.toArray(), horizontalHull: hull(horizontal), verticalHull: hull(vertical) },
meshCount,
triangles,
bytes: model.bytes.byteLength,
sourceBytes: model.sourceBytes,
}
await mkdir(directory, { recursive: true })
await writeFile(new URL('CloudKitchen.usdz', directory), model.bytes)
await writeFile(new URL('CloudKitchenManifest.json', directory), `${JSON.stringify(manifest, null, 2)}\n`)
await mkdir(publicDirectory, { recursive: true })
for (const ingredient of ingredientCatalog) {
const root = new THREE.Group()
root.name = 'IngredientPreview'
root.add(foodLibrary.create(ingredient.name))
const camera = new THREE.OrthographicCamera(-0.43, 0.43, 0.43, -0.43, 0.01, 20)
camera.name = 'IconCamera'
camera.position.set(1.3, 1.1, 1.8)
camera.lookAt(0, 0.19, 0)
root.add(camera)
root.updateMatrixWorld(true)
bakeNormals(root)
const asset = await packageModel(ingredient.id, root)
await copyFile(asset.packed, new URL(`${ingredient.id}.usdz`, publicDirectory))
const icon = join(temporary, `${ingredient.id}.png`)
await run('usdrecord', ['--camera', 'IconCamera', '--imageWidth', '256', asset.packed, icon])
await copyFile(icon, new URL(`${ingredient.id}.png`, publicDirectory))
const imageset = new URL(`Assets.xcassets/KitchenIngredient_${ingredient.id}.imageset/`, directory)
await mkdir(imageset, { recursive: true })
await copyFile(icon, new URL('model.png', imageset))
await writeFile(new URL('Contents.json', imageset), `${JSON.stringify({ images: [{ filename: 'model.png', idiom: 'universal' }], info: { author: 'xcode', version: 1 } }, null, 2)}\n`)
}
await writeFile(new URL('manifest.json', publicDirectory), `${JSON.stringify(manifest, null, 2)}\n`)
console.log(`Exported ${meshCount} meshes / ${triangles} triangles / ${(model.bytes.byteLength / 1024 / 1024).toFixed(2)} MiB (from ${(model.sourceBytes / 1024 / 1024).toFixed(2)} MiB) to ${fileURLToPath(directory)}`)
}
finally {
exportedGeometry.forEach(geometry => geometry.dispose())
exportedMaterials.forEach(material => material.dispose())
kitchen.dispose()
foodLibrary.dispose()
await rm(temporary, { recursive: true, force: true })
}