From 86687ea6b99b20f6e6bdebc859cca0199766f9e2 Mon Sep 17 00:00:00 2001 From: Misode Date: Tue, 4 Oct 2022 19:15:28 +0200 Subject: [PATCH] Improve item display (#283) * Refactor item display to separate component * Load assets and render item models * Cache rendered items --- package-lock.json | 14 ++-- package.json | 2 +- src/app/Utils.ts | 6 +- src/app/components/ItemDisplay.tsx | 58 +++++++++++++++++ src/app/schema/renderHtml.tsx | 12 +--- src/app/services/DataFetcher.ts | 62 ++++++++++++++---- src/app/services/Resources.ts | 101 +++++++++++++++++++++++++++++ src/styles/global.css | 26 ++++++++ src/styles/nodes.css | 15 ----- 9 files changed, 250 insertions(+), 46 deletions(-) create mode 100644 src/app/components/ItemDisplay.tsx create mode 100644 src/app/services/Resources.ts diff --git a/package-lock.json b/package-lock.json index debbdb60..13fe256e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "brace": "^0.11.1", "buffer": "^6.0.3", "comment-json": "^4.1.1", - "deepslate": "^0.12.0-beta.1", + "deepslate": "^0.13.0", "deepslate-1.18": "npm:deepslate@^0.9.0-beta.9", "deepslate-1.18.2": "npm:deepslate@^0.9.0-beta.13", "highlight.js": "^11.5.1", @@ -1933,9 +1933,9 @@ "dev": true }, "node_modules/deepslate": { - "version": "0.12.0-beta.1", - "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.12.0-beta.1.tgz", - "integrity": "sha512-gxYokRPgnQ7Hrb8k4iJPA23gNBC8VIWXJVNDuiWiZLYaFhZec3HkkZZXqn+Ba/z3gIDinpatCdiQKP/8gJyZzA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.13.0.tgz", + "integrity": "sha512-16Dh/dOc8RLtiL0aQ3/h7bHUcIer+jAfFXehavhvHquEkxncQyZDq9YGktPTm9gTD2VGTDZeuIwZWAiMYkdfqw==", "dependencies": { "gl-matrix": "^3.3.0", "md5": "^2.3.0", @@ -6621,9 +6621,9 @@ "dev": true }, "deepslate": { - "version": "0.12.0-beta.1", - "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.12.0-beta.1.tgz", - "integrity": "sha512-gxYokRPgnQ7Hrb8k4iJPA23gNBC8VIWXJVNDuiWiZLYaFhZec3HkkZZXqn+Ba/z3gIDinpatCdiQKP/8gJyZzA==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/deepslate/-/deepslate-0.13.0.tgz", + "integrity": "sha512-16Dh/dOc8RLtiL0aQ3/h7bHUcIer+jAfFXehavhvHquEkxncQyZDq9YGktPTm9gTD2VGTDZeuIwZWAiMYkdfqw==", "requires": { "gl-matrix": "^3.3.0", "md5": "^2.3.0", diff --git a/package.json b/package.json index 22f8fe93..449672a3 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "brace": "^0.11.1", "buffer": "^6.0.3", "comment-json": "^4.1.1", - "deepslate": "^0.12.0-beta.1", + "deepslate": "^0.13.0", "deepslate-1.18": "npm:deepslate@^0.9.0-beta.9", "deepslate-1.18.2": "npm:deepslate@^0.9.0-beta.13", "highlight.js": "^11.5.1", diff --git a/src/app/Utils.ts b/src/app/Utils.ts index 2e8a8bd8..864312a4 100644 --- a/src/app/Utils.ts +++ b/src/app/Utils.ts @@ -297,12 +297,12 @@ export class BiMap { } } -export async function readZip(file: File): Promise<[string, string][]> { - const buffer = await file.arrayBuffer() +export async function readZip(file: File | ArrayBuffer, predicate: (name: string) => boolean = () => true): Promise<[string, string][]> { + const buffer = file instanceof File ? await file.arrayBuffer() : file const reader = new zip.ZipReader(new zip.BlobReader(new Blob([buffer]))) const entries = await reader.getEntries() return await Promise.all(entries - .filter(e => !e.directory) + .filter(e => !e.directory && predicate(e.filename)) .map(async e => { const writer = new zip.TextWriter('utf-8') return [e.filename, await e.getData?.(writer)] as [string, string] diff --git a/src/app/components/ItemDisplay.tsx b/src/app/components/ItemDisplay.tsx new file mode 100644 index 00000000..60fc82ea --- /dev/null +++ b/src/app/components/ItemDisplay.tsx @@ -0,0 +1,58 @@ +import { useState } from 'preact/hooks' +import { useVersion } from '../contexts/Version.jsx' +import { useAsync } from '../hooks/useAsync.js' +import { getAssetUrl } from '../services/DataFetcher.js' +import { renderItem } from '../services/Resources.js' +import { getCollections } from '../services/Schemas.js' +import { Octicon } from './Octicon.jsx' + +interface Props { + item: string, +} +export function ItemDisplay({ item }: Props) { + const { version } = useVersion() + const [errored, setErrored] = useState(false) + + if (errored || (item.includes(':') && !item.startsWith('minecraft:'))) { + return
+ {Octicon.package} +
+ } + + const { value: collections } = useAsync(() => getCollections(version), []) + + if (collections === undefined) { + return
+ } + + const texturePath = `item/${item.replace(/^minecraft:/, '')}` + if (collections.get('texture').includes('minecraft:' + texturePath)) { + return
+ setErrored(true)} /> +
+ } + + const modelPath = `block/${item.replace(/^minecraft:/, '')}` + if (collections.get('model').includes('minecraft:' + modelPath)) { + return
+ +
+ } + + return
+ {Octicon.package} +
+} + +function RenderedItem({ item }: Props) { + const { version } = useVersion() + const { value: src } = useAsync(() => renderItem(version, item), [version, item]) + + if (src) { + return {item} + } + + return
+ {Octicon.package} +
+} diff --git a/src/app/schema/renderHtml.tsx b/src/app/schema/renderHtml.tsx index 4168e5ad..83568f87 100644 --- a/src/app/schema/renderHtml.tsx +++ b/src/app/schema/renderHtml.tsx @@ -4,12 +4,13 @@ import type { ComponentChildren, JSX } from 'preact' import { memo } from 'preact/compat' import { useState } from 'preact/hooks' import { Btn, Octicon } from '../components/index.js' +import { ItemDisplay } from '../components/ItemDisplay.jsx' import config from '../Config.js' import { localize, useStore } from '../contexts/index.js' import { useFocus } from '../hooks/index.js' import { VanillaColors } from '../previews/index.js' import type { BlockStateRegistry, VersionId } from '../services/index.js' -import { CachedCollections, CachedDecorator, CachedFeature, getTextureUrl } from '../services/index.js' +import { CachedDecorator, CachedFeature } from '../services/index.js' import { deepClone, deepEqual, generateUUID, hexId, hexToRgb, isObject, newSeed, rgbToHex, stringToColor } from '../Utils.js' import { ModelWrapper } from './ModelWrapper.js' @@ -22,8 +23,6 @@ const fixedLists = ['generator_biome.parameters.temperature', 'generator_biome.p const collapsedFields = ['noise_settings.surface_rule', 'noise_settings.noise.terrain_shaper'] const collapsableFields = ['density_function.argument', 'density_function.argument1', 'density_function.argument2', 'density_function.input', 'density_function.when_in_range', 'density_function.when_out_of_range'] -const PACKAGE = '' - const findGenerator = (id: string) => { return config.generators.find(g => g.id === id.replace(/^\$/, '')) } @@ -140,12 +139,7 @@ const renderHtml: RenderHook = { let label: undefined | string | JSX.Element if (['loot_pool.entries.entry', 'loot_entry.alternatives.children.entry', 'loot_entry.group.children.entry', 'loot_entry.sequence.children.entry', 'function.set_contents.entries.entry'].includes(cPath.getContext().join('.'))) { if (isObject(cValue) && typeof cValue.type === 'string' && cValue.type.replace(/^minecraft:/, '') === 'item' && typeof cValue.name === 'string') { - const texturePath = `item/${cValue.name.replace(/^minecraft:/, '')}` - if (CachedCollections.get('texture').includes('minecraft:' + texturePath)) { - label = e.currentTarget.outerHTML = PACKAGE} /> - } else { - label = Octicon.package - } + label = } } diff --git a/src/app/services/DataFetcher.ts b/src/app/services/DataFetcher.ts index e49ae7bb..bf003a9a 100644 --- a/src/app/services/DataFetcher.ts +++ b/src/app/services/DataFetcher.ts @@ -16,24 +16,25 @@ type Version = { declare var __LATEST_VERSION__: string const latestVersion = __LATEST_VERSION__ ?? '' const mcmetaUrl = 'https://raw.githubusercontent.com/misode/mcmeta' +const mcmetaTarballUrl = 'https://github.com/misode/mcmeta/tarball' const changesUrl = 'https://raw.githubusercontent.com/misode/technical-changes' -type McmetaTypes = 'summary' | 'data' | 'assets' | 'registries' +type McmetaTypes = 'summary' | 'data' | 'data-json' | 'assets' | 'assets-json' | 'registries' | 'atlas' interface RefInfo { dynamic?: boolean ref?: string } -function mcmeta(version: RefInfo, type: McmetaTypes) { - return `${mcmetaUrl}/${version.dynamic ? type : `${version.ref}-${type}`}` +function mcmeta(version: RefInfo, type: McmetaTypes, tarball?: boolean) { + return `${tarball ? mcmetaTarballUrl : mcmetaUrl}/${version.dynamic ? type : `${version.ref}-${type}`}` } async function validateCache(version: RefInfo) { await applyPatches() if (version.dynamic) { if (localStorage.getItem(CACHE_LATEST_VERSION) !== latestVersion) { - await deleteMatching(url => url.startsWith(`${mcmetaUrl}/summary/`) || url.startsWith(`${mcmetaUrl}/data/`) || url.startsWith(`${mcmetaUrl}/assets/`) || url.startsWith(`${mcmetaUrl}/registries/`)) + await deleteMatching(url => url.startsWith(`${mcmetaUrl}/summary/`) || url.startsWith(`${mcmetaUrl}/data/`) || url.startsWith(`${mcmetaUrl}/assets/`) || url.startsWith(`${mcmetaUrl}/registries/`) || url.startsWith(`${mcmetaUrl}/atlas/`) || url.startsWith(`${mcmetaTarballUrl}/assets-json/`)) localStorage.setItem(CACHE_LATEST_VERSION, latestVersion) } version.ref = latestVersion @@ -105,11 +106,8 @@ export async function fetchAllPresets(versionId: VersionId, registry: string) { const version = config.versions.find(v => v.id === versionId)! await validateCache(version) try { - const entries = await cachedFetch(`${mcmeta(version, 'registries')}/${registry}/data.min.json`) - return new Map(await Promise.all( - entries.map(async (e: string) => - [e, await cachedFetch(`${mcmeta(version, 'data')}/data/minecraft/${registry}/${e}.json`)]) - )) + const type = ['block_definition', 'model', 'font'].includes(registry) ? 'assets' : 'data' + return new Map(Object.entries(await cachedFetch(`${mcmeta(version, 'summary')}/${type}/${registry}/data.min.json`))) } catch (e) { throw new Error(`Error occurred while fetching all ${registry} presets: ${message(e)}`) } @@ -159,11 +157,53 @@ export async function fetchVersions(): Promise { } } -export function getTextureUrl(versionId: VersionId, path: string): string { +export function getAssetUrl(versionId: VersionId, type: string, path: string): string { const version = config.versions.find(v => v.id === versionId)! - return `${mcmeta(version, 'assets')}/assets/minecraft/textures/${path}.png` + return `${mcmeta(version, 'assets')}/assets/minecraft/${type}/${path}.png` } +export async function fetchResources(versionId: VersionId) { + const version = config.versions.find(v => v.id === versionId)! + await validateCache(version) + try { + const [models, uvMapping, atlas] = await Promise.all([ + fetchAllPresets(versionId, 'model'), + cachedFetch(`${mcmeta(version, 'atlas')}/all/data.min.json`), + loadImage(`${mcmeta(version, 'atlas')}/all/atlas.png`), + ]) + return { models, uvMapping, atlas } + } catch (e) { + throw new Error(`Error occured while fetching resources: ${message(e)}`) + } +} + +async function loadImage(src: string) { + return new Promise(res => { + const image = new Image() + image.onload = () => res(image) + image.crossOrigin = 'Anonymous' + image.src = src + }) +} + +/* +async function loadImage(src: string) { + const buffer = await cachedFetch(src, { decode: r => r.arrayBuffer() }) + const blob = new Blob([buffer], { type: 'image/png' }) + const img = new Image() + img.src = URL.createObjectURL(blob) + return new Promise((res) => { + img.onload = () => { + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d')! + ctx.drawImage(img, 0, 0) + const imgData = ctx.getImageData(0, 0, img.width, img.height) + res(imgData) + } + }) +} +*/ + export interface Change { group: string, version: string, diff --git a/src/app/services/Resources.ts b/src/app/services/Resources.ts new file mode 100644 index 00000000..22cfaacd --- /dev/null +++ b/src/app/services/Resources.ts @@ -0,0 +1,101 @@ +import type { BlockModelProvider, TextureAtlasProvider, UV } from 'deepslate/render' +import { BlockModel, Identifier, ItemRenderer, TextureAtlas, upperPowerOfTwo } from 'deepslate/render' +import { message } from '../Utils.js' +import { fetchResources } from './DataFetcher.js' +import type { VersionId } from './Schemas.js' + +const Resources: Record> = {} + +export async function getResources(version: VersionId) { + if (!Resources[version]) { + Resources[version] = (async () => { + try { + const { models, uvMapping, atlas} = await fetchResources(version) + Resources[version] = new ResourceManager(models, uvMapping, atlas) + return Resources[version] + } catch (e) { + console.error('Error: ', e) + throw new Error(`Cannot get resources for version ${version}: ${message(e)}`) + } + })() + return Resources[version] + } + return Resources[version] +} + +const RENDER_SIZE = 128 +const ItemRenderCache = new Map>() + +export async function renderItem(version: VersionId, item: string) { + const cache_key = `${version} ${item}` + const cached = ItemRenderCache.get(cache_key) + if (cached !== undefined) { + return cached + } + + const promise = (async () => { + const canvas = document.createElement('canvas') + canvas.width = RENDER_SIZE + canvas.height = RENDER_SIZE + const resources = await getResources(version) + const gl = canvas.getContext('webgl2', { preserveDrawingBuffer: true }) + if (!gl) { + throw new Error('Cannot get WebGL2 context') + } + const renderer = new ItemRenderer(gl, Identifier.parse(item), resources) + renderer.drawItem() + return canvas.toDataURL() + })() + ItemRenderCache.set(cache_key, promise) + return promise +} + +export class ResourceManager implements BlockModelProvider, TextureAtlasProvider { + private blockModels: { [id: string]: BlockModel } + private textureAtlas: TextureAtlas + + constructor(models: Map, uvMapping: any, textureAtlas: HTMLImageElement) { + this.blockModels = {} + this.textureAtlas = TextureAtlas.empty() + this.loadBlockModels(models) + this.loadBlockAtlas(textureAtlas, uvMapping) + } + + public getBlockModel(id: Identifier) { + return this.blockModels[id.toString()] + } + + public getTextureUV(id: Identifier) { + return this.textureAtlas.getTextureUV(id) + } + + public getTextureAtlas() { + return this.textureAtlas.getTextureAtlas() + } + + private loadBlockModels(models: Map) { + [...models.entries()].forEach(([id, model]) => { + this.blockModels[Identifier.create(id).toString()] = BlockModel.fromJson(id, model) + }) + Object.values(this.blockModels).forEach(m => m.flatten(this)) + } + + private loadBlockAtlas(image: HTMLImageElement, textures: any) { + const atlasCanvas = document.createElement('canvas') + const w = upperPowerOfTwo(image.width) + const h = upperPowerOfTwo(image.height) + atlasCanvas.width = w + atlasCanvas.height = h + const ctx = atlasCanvas.getContext('2d')! + ctx.drawImage(image, 0, 0) + const imageData = ctx.getImageData(0, 0, w, h) + + const idMap: Record = {} + Object.keys(textures).forEach(id => { + const [u, v, du, dv] = textures[id] + const dv2 = (du !== dv && id.startsWith('block/')) ? du : dv + idMap[Identifier.create(id).toString()] = [u / w, v / h, (u + du) / w, (v + dv2) / h] + }) + this.textureAtlas = new TextureAtlas(imageData, idMap) + } +} diff --git a/src/styles/global.css b/src/styles/global.css index 82337bc3..5ffe44a9 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1163,6 +1163,32 @@ hr { font-weight: 100; } +.item-display { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.item-display > img { + width: 26px; + position: relative; + image-rendering: pixelated; +} + +.item-display > svg { + width: 26px; + height: 20px; + position: relative; + fill: var(--node-text-dimmed); +} + +.item-display > canvas { + width: 32px; + height: 32px; +} + .file-view { background-color: var(--background-2); color: var(--text-2); diff --git a/src/styles/nodes.css b/src/styles/nodes.css index 08820d8c..0699d33b 100644 --- a/src/styles/nodes.css +++ b/src/styles/nodes.css @@ -108,21 +108,6 @@ background-color: var(--node-background-label); } -.node-header > label > img { - height: 80%; - position: relative; - top: 10%; - image-rendering: pixelated; -} - -.node-header > label > svg { - width: 25.59px; - height: 20px; - position: relative; - top: 5px; - fill: var(--node-text-dimmed); -} - .node-header > input { font-size: 18px; padding-left: 9px;