diff --git a/.gitignore b/.gitignore index c6bba59..7222a96 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,5 @@ dist .yarn/build-state.yml .yarn/install-state.gz .pnp.* + +.idea/ \ No newline at end of file diff --git a/package.json b/package.json index bec9953..dc50558 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dotenv": "^16.4.5", "hono": "^4.3.11", "listhen": "^1.7.2", + "lru-cache": "^10.3.0", "tsx": "^4.10.5", "zod": "^3.23.8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed3a22b..a2363ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: listhen: specifier: ^1.7.2 version: 1.7.2 + lru-cache: + specifier: ^10.3.0 + version: 10.3.0 tsx: specifier: ^4.10.5 version: 4.10.5 @@ -756,6 +759,10 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lru-cache@10.3.0: + resolution: {integrity: sha512-CQl19J/g+Hbjbv4Y3mFNNXFEL/5t/KCg8POCuUqd4rMKjGG+j1ybER83hxV58zL+dFI1PTkt3GNFSHRt+d8qEQ==} + engines: {node: 14 || >=16.14} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -1713,6 +1720,8 @@ snapshots: lodash.merge@4.6.2: {} + lru-cache@10.3.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} diff --git a/src/routes/renderer.ts b/src/routes/renderer.ts index 362fc3e..4eaf60b 100644 --- a/src/routes/renderer.ts +++ b/src/routes/renderer.ts @@ -2,69 +2,59 @@ import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' -import blockStateData from '../data/renderer/block_states.json' -import blockModelData from '../data/renderer/block_models.json' -import textureAtlasData from '../data/renderer/atlas.json' -import blockRenderTypeData from '../data/renderer/block_render_type.json' -import blockOcclusionShapeData from '../data/renderer/block_occlusion_shape.json' -import specialBlocksData from '../data/renderer/special.json' -import liquidComputationData from '../data/renderer/block_liquid_computation.json' +import _blockStateData from '../data/renderer/block_states.json' +import _blockModelData from '../data/renderer/block_models.json' +import _textureAtlasData from '../data/renderer/atlas.json' +import _blockRenderTypeData from '../data/renderer/block_render_type.json' +import _blockOcclusionShapeData from '../data/renderer/block_occlusion_shape.json' +import _specialBlocksData from '../data/renderer/special.json' +import _liquidComputationData from '../data/renderer/block_liquid_computation.json' +import { LRUCache } from 'lru-cache' + +const blockStateData = _blockStateData as unknown as Record +const blockModelData = _blockModelData as unknown as Record +const textureAtlasData = _textureAtlasData as unknown as Record +const blockRenderTypeData = _blockRenderTypeData as unknown as Record +const blockOcclusionShapeData = _blockOcclusionShapeData as unknown as Record< + string, + OcclusionFaceData +> +const specialBlocksData = _specialBlocksData as unknown as Record +const liquidComputationData = _liquidComputationData as unknown as Record< + string, + LiquidComputationData +> const app = new Hono() -const querySchema = z.record(z.string()) +// Query Schema ------------------------------------------------------------------------------------ -// Block Model Structure - -export interface BlockModel { - elements?: ModelElement[] -} - -export interface ModelElement { - from: number[] - to: number[] - rotation?: ModelRotation - shade?: boolean - faces: { - down?: ModelFace - up?: ModelFace - north?: ModelFace - south?: ModelFace - west?: ModelFace - east?: ModelFace - } -} - -export interface ModelFace { - texture: number - uv?: number[] - rotation?: number - tintindex?: number - cullface?: string -} +const BlockState = z.object({ + name: z.string(), + properties: z.record(z.string()).optional(), +}) +type BlockState = z.infer -// Block State Structure +const querySchema = z.object({ + states: z.array(BlockState), + hash: z.string(), +}) -export interface ModelRotation { - origin: number[] - axis: 'x' | 'y' | 'z' | string - angle: number - rescale?: boolean -} +// Data Definitions -------------------------------------------------------------------------------- -export interface BlockStateModelCollection { +interface BlockStateModelCollection { variants?: Record multipart?: ConditionalPart[] } -export interface ModelReference { +interface ModelReference { model: number uvlock?: boolean x?: number y?: number } -export interface ModelReferenceWithWeight { +interface ModelReferenceWithWeight { model: number uvlock?: boolean x?: number @@ -72,30 +62,20 @@ export interface ModelReferenceWithWeight { weight?: number } -export interface ConditionalPart { +interface ConditionalPart { apply: ModelReference | ModelReferenceWithWeight[] - when?: Record | AndCondition | OrCondition + when?: Record | AndCondition | OrCondition } -export interface AndCondition { - AND: (Record | AndCondition | OrCondition)[] +interface AndCondition { + AND: (Record | AndCondition | OrCondition)[] } -export interface OrCondition { - OR: (Record | AndCondition | OrCondition)[] -} - -// Block Texture Structure - -export interface AnimatedTexture { - frames: number[] - time: number[] - interpolate?: boolean +interface OrCondition { + OR: (Record | AndCondition | OrCondition)[] } -// Occlusion Face Structure - -export interface OcclusionFaceData { +interface OcclusionFaceData { down?: number[][] up?: number[][] north?: number[][] @@ -105,183 +85,306 @@ export interface OcclusionFaceData { can_occlude: boolean } -// Liquid Computation Data - -export interface LiquidComputationData { +interface LiquidComputationData { blocks_motion: boolean face_sturdy: string[] } -app.get('/', zValidator('query', querySchema), (ctx) => { - const query = ctx.req.valid('query') - - const foundBlocks: string[] = [] - const foundBlockRenderType: Record = {} - const foundBlockStates: Record = {} - const foundBlockModel: Record = {} - const foundTextureAtlas: Record = {} - const foundOcclusionShape: Record = {} - const foundSpecialBlocksData: Record = {} - const foundLiquidComputationData: Record = {} - - for (const [k, v] of Object.entries(query)) { - const modelKey = new Set() - const atlasKey = new Set() - // Split tint data - const tintDataSplitPoint = v.indexOf('!') - let tint: string | null = null - let unresolvedBlockState: string - - if (tintDataSplitPoint !== -1) { - tint = v.substring(tintDataSplitPoint + 1) - unresolvedBlockState = v.substring(0, tintDataSplitPoint - 1) - } else { - unresolvedBlockState = v - } +interface BlockModel { + elements?: ModelElement[] +} - // Sort block properties - const splitPoint = unresolvedBlockState.indexOf('[') - let block: string - let state - let nonWaterLoggedState - if (splitPoint !== -1) { - block = unresolvedBlockState.substring(0, splitPoint - 1) - const stateList = unresolvedBlockState.substring(splitPoint + 1, -2).split(',') - const unsortedStateMap: Record = {} - const stateNameList: string[] = [] - for (const state of stateList) { - const splitNamePoint = state.indexOf('=') - const stateName = state.substring(0, splitNamePoint - 1) - const stateValue = state.substring(splitNamePoint + 1) - unsortedStateMap[stateName] = stateValue - stateNameList.push(stateName) - } - stateNameList.sort() - const sortedStateList: string[] = [] - const sortedStateListNonWaterLogged: string[] = [] - for (const stateName of stateNameList) { - sortedStateList.push(stateName + '=' + unsortedStateMap[stateName]) - if (stateName === 'waterlogged') - sortedStateListNonWaterLogged.push(stateName + '=' + unsortedStateMap[stateName]) - else { - foundSpecialBlocksData['water'] = specialBlocksData['water'] - specialBlocksData.water.forEach((texture) => atlasKey.add(texture.toString())) +interface ModelElement { + from: number[] + to: number[] + rotation?: never + shade?: boolean + faces: { + down?: ModelFace + up?: ModelFace + north?: ModelFace + south?: ModelFace + west?: ModelFace + east?: ModelFace + } +} + +interface ModelFace { + texture: number + uv?: number[] + rotation?: number + tintindex?: number + cullface?: string +} + +interface AnimatedTexture { + frames: number[] + time: number[] + interpolate?: boolean +} + +// Model Selection Algorithm ----------------------------------------------------------------------- + +function conditionMatch( + condition: Record | AndCondition | OrCondition, + blockProperties: Record, +): boolean { + if ('AND' in condition) { + return (condition as AndCondition).AND.every( + (part: Record | AndCondition | OrCondition) => + conditionMatch(part, blockProperties), + ) + } else if ('OR' in condition) { + return (condition as OrCondition).OR.some( + (part: Record | AndCondition | OrCondition) => + conditionMatch(part, blockProperties), + ) + } else { + return Object.entries(condition).every(([key, value]) => { + const reversed = value.startsWith('!') + if (reversed) value = value.slice(1) + return value.split('|').includes(blockProperties[key]) !== reversed + }) + } +} + +function chooseModel( + blockState: BlockState, +): (ModelReference | ModelReferenceWithWeight[])[] | null { + const modelCollection = blockStateData[blockState.name] + + if (!modelCollection) return null + if (!modelCollection.variants && !modelCollection.multipart) return null + + const blockProperties = blockState.properties ?? {} + if (modelCollection.variants) { + for (const [key, value] of Object.entries(modelCollection.variants)) { + const stateCondition = key.split(',') + let match = true + for (const condition of stateCondition) { + const [key, value] = condition.split('=') + if (blockProperties[key] !== value) { + match = false + break } } - state = '[' + sortedStateList.join(',') + ']' - nonWaterLoggedState = '[' + sortedStateListNonWaterLogged.join(',') + ']' - } else { - if ( - unresolvedBlockState == 'seagrass' || - unresolvedBlockState == 'kelp' || - unresolvedBlockState == 'bubble_column' - ) { - foundSpecialBlocksData['water'] = specialBlocksData['water'] - specialBlocksData.water.forEach((texture) => atlasKey.add(texture.toString())) - } - block = unresolvedBlockState - state = '' - nonWaterLoggedState = '' + if (match) return [value] } + } else { + const matchingPart = modelCollection.multipart!.filter((part) => + conditionMatch(part.when ?? {}, blockProperties), + ) + if (matchingPart.length === 0) return null + return matchingPart.map((part) => part.apply) + } - const resolvedBlockState = tint !== null ? block + state + '!' + tint : block + state + return null +} - foundBlocks.push(k + '=' + resolvedBlockState) +// Response Scheme --------------------------------------------------------------------------------- - if (keyInObject(block, blockRenderTypeData)) { - foundBlockRenderType[block] = blockRenderTypeData[block] - } +interface StateData { + parts: (ModelReference | ModelReferenceWithWeight[])[] + models: number[] + textures: number[] + render_type: string + face_sturdy: string[] + blocks_motion: boolean + occlusion: boolean + occlusion_shape: Record + special_textures: number[] +} - if (keyInObject(block, specialBlocksData)) { - foundSpecialBlocksData[block] = specialBlocksData[block] - } +interface Response { + processed: boolean +} - if (keyInObject(block, blockStateData)) { - foundBlockStates[block] = blockStateData[block] - } +interface NotProcessedResponse extends Response { + processed: false +} - getModelForBlock(foundBlockStates[block]).forEach((value) => modelKey.add(value)) +const NOT_PROCESSED_RESPONSE: NotProcessedResponse = { processed: false } + +interface ProcessedResponse extends Response { + states: { + state: BlockState + parts: (ModelReference | ModelReferenceWithWeight[])[] + render_type: string + face_sturdy: string[] + blocks_motion: boolean + occlusion: boolean + occlusion_shape: Record + special_textures: number[] + }[] + models: Record + textures: Record + processed: true +} - for (const key of modelKey) { - if (keyInObject(key, blockModelData)) { - foundBlockModel[key] = blockModelData[key] - const blockModel: BlockModel = blockModelData[key] - if (!blockModel.elements) continue +function stateToString(blockState: BlockState): string { + if (blockState.properties && Object.keys(blockState.properties).length > 0) { + return `${blockState.name}[${Object.entries(blockState.properties) + .sort(([key1], [key2]) => key1.localeCompare(key2)) + .map(([key, value]) => `${key}=${value}`) + .join(',')}]` + } else { + return blockState.name + } +} - for (const element of blockModel.elements) { - for (const face of Object.values(element.faces)) { - atlasKey.add(face.texture.toString()) - } - } - } - } +function stateToStringFilteredWaterLogged(blockState: BlockState): string { + const filtered = Object.entries(blockState.properties ?? {}).filter( + ([key]) => key !== 'waterlogged', + ) + if (filtered.length > 0) { + return `${blockState.name}[${filtered + .sort(([key1], [key2]) => key1.localeCompare(key2)) + .map(([key, value]) => `${key}=${value}`) + .join(',')}]` + } else { + return blockState.name + } +} - for (const key of atlasKey) { - if (keyInObject(key, textureAtlasData)) { - foundTextureAtlas[key] = textureAtlasData[key] +// Cache ------------------------------------------------------------------------------------------- + +const EMPTY_STATE_DATA: StateData = { + parts: [], + models: [], + textures: [], + render_type: 'solid', + face_sturdy: [], + blocks_motion: false, + occlusion: false, + occlusion_shape: {}, + special_textures: [], +} + +const BLOCK_CACHE = new LRUCache({ + max: 2000, + memoMethod: (_key, _staleValue, options) => makeStateData(options.context), +}) + +function findOrMakeData(blockState: BlockState): StateData { + return BLOCK_CACHE.memo(stateToString(blockState), { context: blockState }) +} + +function makeStateData(blockState: BlockState): StateData { + const stateString = stateToStringFilteredWaterLogged(blockState) + const stateName = blockState.name + + const parts = chooseModel(blockState) + const specials = specialBlocksData[stateName] + const occlusionShape = blockOcclusionShapeData[stateString] ?? { can_occlude: false } + const liquidComputation = liquidComputationData[stateString] ?? { + blocks_motion: false, + face_sturdy: [], + } - const atlasData: AnimatedTexture | number[] = textureAtlasData[key] + if (!parts && !specials && !occlusionShape.can_occlude && !liquidComputation.blocks_motion) + return EMPTY_STATE_DATA - if (!(atlasData instanceof Array)) { - for (const frame of atlasData.frames) { - atlasKey.add(frame.toString()) - } + const models = new Set() + if (parts) { + for (const part of parts) { + if (Array.isArray(part)) { + for (const model of part) { + models.add(model.model) } + } else { + models.add(part.model) } } + } - const searchKey = block + nonWaterLoggedState - - if (keyInObject(searchKey, blockOcclusionShapeData)) { - foundOcclusionShape[k] = blockOcclusionShapeData[searchKey] + const textures = new Set() + specials?.forEach((val) => textures.add(val)) + for (const model of models) { + const modelData = blockModelData[String(model)] + if (modelData) { + for (const element of modelData.elements ?? []) { + for (const face of Object.values(element.faces)) { + textures.add(face.texture) + } + } } + } - if (keyInObject(block, liquidComputationData)) { - foundLiquidComputationData[k] = liquidComputationData[block] + const texturesParsed = new Set() + for (const texture of textures) { + const textureData = textureAtlasData[String(texture)] + if (Array.isArray(textureData)) { + texturesParsed.add(texture) + } else { + texturesParsed.add(texture) + textureData.frames.forEach((val) => texturesParsed.add(val)) } } - return ctx.json({ - blockStates: foundBlockStates, - blockModel: foundBlockModel, - textureAtlas: foundTextureAtlas, - blockRenderType: foundBlockRenderType, - occlusionShape: foundOcclusionShape, - specialBlocksData: foundSpecialBlocksData, - liquidComputationData: foundLiquidComputationData, - }) + return { + parts: parts ?? [], + models: Array.from(models), + textures: Array.from(texturesParsed), + render_type: blockRenderTypeData[stateName] ?? 'solid', + face_sturdy: liquidComputation.face_sturdy, + blocks_motion: liquidComputation.blocks_motion, + occlusion: occlusionShape.can_occlude, + occlusion_shape: { + down: occlusionShape.down ?? [], + up: occlusionShape.up ?? [], + north: occlusionShape.north ?? [], + south: occlusionShape.south ?? [], + west: occlusionShape.west ?? [], + east: occlusionShape.east ?? [], + }, + special_textures: specials ?? [], + } +} + +const RESOLVED_CACHE = new LRUCache({ + max: 1000, }) -function keyInObject( - key: string | number | symbol, - obj: T, -): key is keyof typeof obj { - return key in obj -} +// Routes ------------------------------------------------------------------------------------------ -function getModelForBlock(blockState?: BlockStateModelCollection) { - if (blockState === undefined) return new Set() - const modelKey = new Set() - if (blockState.multipart) { - for (const part of blockState.multipart) { - if (part.apply instanceof Array) { - for (const apply of part.apply) { - modelKey.add(apply.model.toString()) - } - } else modelKey.add(part.apply.model.toString()) - } - } - if (blockState.variants) { - for (const model of Object.values(blockState.variants)) { - if (model instanceof Array) { - for (const m of model) { - modelKey.add(m.model.toString()) - } - } else modelKey.add(model.model.toString()) - } +app.get('/:hash', (ctx) => { + const hash = ctx.req.param('hash') + const response = RESOLVED_CACHE.get(hash) + if (response) + return ctx.json(response, 200, { + 'Cache-Control': 'public, max-age=86400, s-maxage=604800', + }) + return ctx.json(NOT_PROCESSED_RESPONSE, 404) +}) + +app.post('/process', zValidator('json', querySchema), (ctx) => { + const query = ctx.req.valid('json') + const response = { states: [], textures: {}, models: {}, processed: true } as ProcessedResponse + const collectedTextures = new Set() + const collectedModels = new Set() + + for (const blockState of query.states) { + const data = findOrMakeData(blockState) + response.states.push({ + state: blockState, + parts: data.parts, + render_type: data.render_type, + face_sturdy: data.face_sturdy, + blocks_motion: data.blocks_motion, + occlusion: data.occlusion, + occlusion_shape: data.occlusion_shape, + special_textures: data.special_textures, + }) + data.textures.forEach((texture) => collectedTextures.add(String(texture))) + data.special_textures.forEach((texture) => collectedTextures.add(String(texture))) + data.models.forEach((model) => collectedModels.add(String(model))) } - return modelKey -} + collectedTextures.forEach((tex) => (response.textures[tex] = textureAtlasData[tex])) + collectedModels.forEach((model) => (response.models[model] = blockModelData[model])) + RESOLVED_CACHE.set(query.hash, response) + + return ctx.json(response) +}) export default app