Refactored atlas and palette loading and handling

This commit is contained in:
Lucas Dower 2022-08-19 18:58:17 +01:00
parent b16f253124
commit 0f46c16ccc
6 changed files with 351 additions and 125 deletions

128
src/atlas.ts Normal file
View File

@ -0,0 +1,128 @@
import { AppError, AppTypes, AppUtil, ASSERT, ATLASES_DIR, TOptional, UV } from './util';
import fs from 'fs';
import path from 'path';
import { RGBA, RGBAColours, RGBAUtil } from './colour';
import { Palette } from './palette';
export type TAtlasBlockFace = {
name: string;
texcoord: UV;
}
export type TAtlasBlock = {
name: string;
colour: RGBA;
faces: {
up: TAtlasBlockFace,
down: TAtlasBlockFace,
north: TAtlasBlockFace,
east: TAtlasBlockFace,
south: TAtlasBlockFace,
west: TAtlasBlockFace,
};
}
/**
* Atlases, unlike palettes, are not currently designed to be user-facing or
* programmatically created. This class simply facilitates loading .atlas
* files.
*/
export class Atlas {
public static ATLAS_NAME_REGEX: RegExp = /^[a-zA-Z\-]+$/;
private static _FILE_VERSION: number = 1;
private _blocks: Map<AppTypes.TNamespacedBlockName, TAtlasBlock>;
private _atlasSize: number;
private _atlasName: string;
private constructor(atlasName: string) {
this._blocks = new Map<AppTypes.TNamespacedBlockName, TAtlasBlock>();
this._atlasSize = 0;
this._atlasName = atlasName;
}
public static load(atlasName: string): TOptional<Atlas> {
if (!Atlas._isValidAtlasName(atlasName)) {
return;
}
const atlasPath = Atlas._getAtlasPath(atlasName);
if (!fs.existsSync(atlasPath)) {
return;
}
const atlas = new Atlas(atlasName);
const atlasFile = fs.readFileSync(atlasPath, 'utf8');
const atlasJSON = JSON.parse(atlasFile);
const atlasVersion = atlasJSON.version;
if (atlasVersion === undefined || atlasVersion === 1) {
const atlasSize = atlasJSON.atlasSize as number;
atlas._atlasSize = atlasSize;
const blocks = atlasJSON.blocks;
for (const block of blocks) {
const atlasBlock = block as TAtlasBlock;
atlasBlock.name = AppUtil.Text.namespaceBlock(atlasBlock.name);
atlas._blocks.set(atlasBlock.name, atlasBlock);
}
} else {
ASSERT(false, `Unrecognised .atlas file version: ${atlasVersion}`);
}
return atlas;
}
public getAtlasSize(): number {
return this._atlasSize;
}
public getAtlasTexturePath() {
return path.join(ATLASES_DIR, `./${this._atlasName}.png`);
}
public getBlock(voxelColour: RGBA, palette: Palette, blocksToExclude?: AppTypes.TNamespacedBlockName[]) {
const blocksToUse = palette.getBlocks();
// Remove excluded blocks
if (blocksToExclude !== undefined) {
for (const blockToExclude of blocksToExclude) {
const index = blocksToUse.indexOf(blockToExclude);
if (index != -1) {
blocksToUse.splice(index, 1);
}
}
}
// Find closest block in colour
let minDistance = Infinity;
let blockChoice: TOptional<AppTypes.TNamespacedBlockName>;
for (const blockName of blocksToUse) {
const blockData = this._blocks.get(blockName);
ASSERT(blockData);
const colourDistance = RGBAUtil.squaredDistance(voxelColour, blockData.colour);
if (colourDistance < minDistance) {
minDistance = colourDistance;
blockChoice = blockName;
}
}
if (blockChoice !== undefined) {
return this._blocks.get(blockChoice)!;
}
throw new AppError('Could not find a suitable block');
}
private static _isValidAtlasName(atlasName: string): boolean {
return atlasName.length > 0 && Atlas.ATLAS_NAME_REGEX.test(atlasName);
}
private static _getAtlasPath(atlasName: string): string {
return path.join(ATLASES_DIR, `./${atlasName}.atlas`);
}
}

View File

@ -4,6 +4,8 @@ import fs from 'fs';
import path from 'path';
import { StatusHandler } from './status';
import { RGBA, RGBAUtil } from './colour';
import { Palette } from './palette';
import { Atlas } from './atlas';
export interface TextureInfo {
name: string
@ -26,19 +28,11 @@ export interface BlockInfo {
faces: FaceInfo
}
interface BlockPalette {
blocks: string[];
}
/*
/* eslint-enable */
export class BlockAtlas {
private _atlasBlocks: Array<BlockInfo>;
private _palette: string[];
private _atlasSize: number;
private _atlasLoaded: boolean;
private _paletteLoaded: boolean;
private _atlasTextureID?: string;
private _paletteBlockToBlockInfoIndex: Map<string, number>;
private _atlas?: Atlas;
private _palette?: Palette;
private static _instance: BlockAtlas;
public static get Get() {
@ -46,119 +40,31 @@ export class BlockAtlas {
}
private constructor() {
this._atlasBlocks = [];
this._atlasSize = 0;
this._atlasLoaded = false;
this._palette = [];
this._paletteLoaded = false;
this._paletteBlockToBlockInfoIndex = new Map();
}
public loadAtlas(atlasID: string) {
const atlasDir = path.join(ATLASES_DIR, atlasID + '.atlas');
ASSERT(fileExists(atlasDir), `Atlas to load does not exist ${atlasDir}`);
const blocksString = fs.readFileSync(atlasDir, 'utf-8');
if (!blocksString) {
throw Error('Could not load vanilla.atlas');
}
const json = JSON.parse(blocksString);
this._atlasSize = json.atlasSize;
this._atlasTextureID = atlasID;
this._atlasBlocks = json.blocks;
for (const block of this._atlasBlocks) {
block.colour = {
r: block.colour.r,
g: block.colour.g,
b: block.colour.b,
a: block.colour.a,
};
}
if (this._atlasBlocks.length === 0) {
throw new AppError('The chosen atlas has no blocks');
}
StatusHandler.Get.add('info', `Atlas '${atlasID}' has data for ${this._atlasBlocks.length} blocks`);
this._atlasLoaded = true;
this._atlas = Atlas.load(atlasID);
}
public loadPalette(paletteID: string) {
ASSERT(this._atlasLoaded, 'An atlas must be loaded before a palette');
const paletteDir = path.join(PALETTES_DIR, paletteID + '.palette');
ASSERT(fileExists(paletteDir), `Palette to load does not exist ${paletteDir}`);
const palette: BlockPalette = JSON.parse(fs.readFileSync(paletteDir, 'utf8'));
this._palette = palette.blocks;
StatusHandler.Get.add('info', `Palette '${paletteID}' has data for ${this._palette.length} blocks`);
// Count the number of palette blocks that are missing from the atlas
// For example, loading an old atlas with a new palette
const missingBlocks: string[] = [];
for (let paletteBlockIndex = palette.blocks.length - 1; paletteBlockIndex >= 0; --paletteBlockIndex) {
const paletteBlockName = palette.blocks[paletteBlockIndex];
const atlasBlockIndex = this._atlasBlocks.findIndex((x) => x.name === paletteBlockName);
if (atlasBlockIndex === -1) {
missingBlocks.push(paletteBlockName);
palette.blocks.splice(paletteBlockIndex, 1);
} else {
this._paletteBlockToBlockInfoIndex.set(paletteBlockName, atlasBlockIndex);
}
}
if (missingBlocks.length > 0) {
StatusHandler.Get.add('warning', `${missingBlocks.length} palette block(s) are missing atlas textures, they will not be used`);
LOG_WARN('Blocks missing atlas textures', missingBlocks);
}
StatusHandler.Get.add('info', `There are ${this._palette.length} valid blocks to assign from`);
this._paletteLoaded = true;
public loadPalette(paletteName: string) {
this._palette = Palette.load(paletteName);
}
public getBlock(voxelColour: RGBA, colourSpace: ColourSpace, exclude?: string[]): BlockInfo {
ASSERT(this._atlasLoaded, 'No atlas has been loaded');
ASSERT(this._paletteLoaded, 'No palette has been loaded');
ASSERT(this._atlas !== undefined, 'Atlas not defined');
ASSERT(this._palette !== undefined, 'Palette not defined');
let minDistance = Infinity;
let blockChoiceIndex!: number;
for (const paletteBlockName of this._palette) {
if (exclude?.includes(paletteBlockName)) {
continue;
}
// TODO: Optimise Use hash map for blockIndex instead of linear search
const blockIndex: (number | undefined) = this._paletteBlockToBlockInfoIndex.get(paletteBlockName);
ASSERT(blockIndex !== undefined);
const block: BlockInfo = this._atlasBlocks[blockIndex];
const blockAvgColour = block.colour as RGBA;
const distance = RGBAUtil.squaredDistance(blockAvgColour, voxelColour);
if (distance < minDistance) {
minDistance = distance;
blockChoiceIndex = blockIndex;
}
}
if (blockChoiceIndex === undefined) {
throw new AppError('The chosen palette does not have suitable blocks');
}
return this._atlasBlocks[blockChoiceIndex];
const block = this._atlas.getBlock(voxelColour, this._palette, exclude);
return block;
}
public getAtlasSize() {
ASSERT(this._atlasLoaded);
return this._atlasSize;
ASSERT(this._atlas !== undefined);
return this._atlas.getAtlasSize();
}
public getAtlasTexturePath() {
ASSERT(this._atlasLoaded, 'No atlas texture available');
ASSERT(this._atlasTextureID, 'No atlas texture ID available');
return path.join(ATLASES_DIR, this._atlasTextureID + '.png');
ASSERT(this._atlas !== undefined, 'Atlas not defined');
return this._atlas.getAtlasTexturePath();
}
}

154
src/palette.ts Normal file
View File

@ -0,0 +1,154 @@
import { AppTypes, AppUtil, ASSERT, LOG_WARN, PALETTES_DIR, TOptional } from './util';
import fs from 'fs';
import path from 'path';
import { BlockInfo } from './block_atlas';
import { StatusHandler } from './status';
export class PaletteManager {
public static getPalettesInfo(): { paletteID: string, paletteDisplayName: string }[] {
const palettes: { paletteID: string, paletteDisplayName: string }[] = [];
fs.readdirSync(PALETTES_DIR).forEach((file) => {
const paletteFilePath = path.parse(file);
if (paletteFilePath.ext === Palette.PALETTE_FILE_EXT) {
const paletteID = paletteFilePath.name;
let paletteDisplayName = paletteID.replace('-', ' ').toLowerCase();
paletteDisplayName = AppUtil.Text.capitaliseFirstLetter(paletteDisplayName);
palettes.push({ paletteID: paletteID, paletteDisplayName: paletteDisplayName });
}
});
return palettes;
}
}
export class Palette {
public static PALETTE_NAME_REGEX: RegExp = /^[a-zA-Z\-]+$/;
public static PALETTE_FILE_EXT: string = '.palette';
private static _FILE_VERSION: number = 1;
private _blocks: AppTypes.TNamespacedBlockName[];
private constructor() {
this._blocks = [];
}
public static create(): Palette {
return new Palette();
}
public static load(paletteName: string): TOptional<Palette> {
if (!Palette._isValidPaletteName(paletteName)) {
return;
}
const palettePath = Palette._getPalettePath(paletteName);
if (!fs.existsSync(palettePath)) {
return;
}
const palette = Palette.create();
const paletteFile = fs.readFileSync(palettePath, 'utf8');
const paletteJSON = JSON.parse(paletteFile);
const paletteVersion = paletteJSON.version;
if (paletteVersion === undefined) {
const paletteBlocks = paletteJSON.blocks;
for (const blockName of paletteBlocks) {
palette.add(AppUtil.Text.namespaceBlock(blockName));
}
} else if (paletteVersion === 1) {
const paletteBlocks = paletteJSON.blocks;
for (const blockName of paletteBlocks) {
palette.add(blockName);
}
} else {
ASSERT(false, `Unrecognised .palette file version: ${paletteVersion}`);
}
return palette;
}
public save(paletteName: string): boolean {
if (!Palette._isValidPaletteName(paletteName)) {
return false;
}
const filePath = Palette._getPalettePath(paletteName);
const paletteJSON = {
version: Palette._FILE_VERSION,
blocks: this._blocks,
};
try {
fs.writeFileSync(filePath, JSON.stringify(paletteJSON, null, 4));
return true;
} catch {
return false;
}
}
public add(blockName: AppTypes.TNamespacedBlockName): void {
if (!this._blocks.includes(blockName)) {
this._blocks.push(AppUtil.Text.namespaceBlock(blockName));
}
}
public remove(blockName: string): boolean {
const index = this._blocks.indexOf(AppUtil.Text.namespaceBlock(blockName));
if (index !== undefined) {
this._blocks.slice(index, 1);
return true;
}
return false;
}
public has(blockName: string): boolean {
return this._blocks.includes(AppUtil.Text.namespaceBlock(blockName));
}
public count() {
return this._blocks.length;
}
public getBlocks() {
return this._blocks;
}
/**
* Checks if each block in this block palette has texture data in the given
* atlas. If not, the block is removed from the palette.
*/
public removeMissingAtlasBlocks(atlasBlocks: BlockInfo[]) {
const atlasBlockNames = new Array<AppTypes.TNamespacedBlockName>(atlasBlocks.length);
atlasBlocks.forEach((atlasBlock, index) => {
atlasBlockNames[index] = AppUtil.Text.namespaceBlock(atlasBlock.name);
});
const missingBlocks: AppTypes.TNamespacedBlockName[] = [];
for (let blockIndex = this._blocks.length-1; blockIndex >= 0; --blockIndex) {
const blockName = this._blocks[blockIndex];
if (!atlasBlockNames.includes(blockName)) {
missingBlocks.push(blockName);
this.remove(blockName);
}
}
if (missingBlocks.length > 0) {
StatusHandler.Get.add('warning', `${missingBlocks.length} palette block(s) are missing atlas textures, they will not be used`);
LOG_WARN('Blocks missing atlas textures', missingBlocks);
}
}
private static _isValidPaletteName(paletteName: string): boolean {
return paletteName.length > 0 && Palette.PALETTE_NAME_REGEX.test(paletteName);
}
private static _getPalettePath(paletteName: string): string {
return path.join(PALETTES_DIR, `./${paletteName}.palette`);
}
}

View File

@ -16,6 +16,7 @@ import { TVoxelisers } from '../voxelisers/voxelisers';
import { TExporters } from '../exporters/exporters';
import { TBlockAssigners } from '../block_assigner';
import { TVoxelOverlapRule } from '../voxel_mesh';
import { PaletteManager } from '../palette';
export interface Group {
label: string;
@ -550,14 +551,13 @@ export class UI {
private _getBlockPalettes(): ComboBoxItem<string>[] {
const blockPalettes: ComboBoxItem<string>[] = [];
fs.readdirSync(PALETTES_DIR).forEach((file) => {
if (file.endsWith('.palette')) {
const paletteID = file.split('.')[0];
let paletteName = paletteID.replace('-', ' ').toLowerCase();
paletteName = paletteName.charAt(0).toUpperCase() + paletteName.slice(1);
blockPalettes.push({ id: paletteID, displayText: paletteName });
}
});
const palettes = PaletteManager.getPalettesInfo();
for (const palette of palettes) {
blockPalettes.push({
id: palette.paletteID,
displayText: palette.paletteDisplayName,
});
}
return blockPalettes;
}

View File

@ -5,6 +5,27 @@ import { clamp } from './math';
import path from 'path';
import fs from 'fs';
export namespace AppUtil {
export namespace Text {
export function capitaliseFirstLetter(text: string) {
return text.charAt(0).toUpperCase() + text.slice(1);
}
/**
* Namespaces a block name if it is not already namespaced
* For example `namespaceBlock('stone')` returns `'minecraft:stone'`
*/
export function namespaceBlock(blockName: string): AppTypes.TNamespacedBlockName {
// https://minecraft.fandom.com/wiki/Resource_location#Namespaces
return blockName.includes(':') ? blockName : ('minecraft:' + blockName);
}
}
}
export namespace AppTypes {
export type TNamespacedBlockName = string;
}
export class UV {
public u: number;
public v: number;

View File

@ -1,10 +1,13 @@
import { log, LogStyle } from './logging';
import { TOOLS_DIR, PALETTES_DIR } from '../src/util';
import { TOOLS_DIR } from '../src/util';
import { Palette } from '../src/palette';
import fs from 'fs';
import path from 'path';
import prompt from 'prompt';
const PALETTE_NAME_REGEX = /^[a-zA-Z\-]+$/;
void async function main() {
log(LogStyle.Info, 'Creating a new palette...');
@ -29,7 +32,7 @@ void async function main() {
const schema: prompt.Schema = {
properties: {
paletteName: {
pattern: /^[a-zA-Z\-]+$/,
pattern: PALETTE_NAME_REGEX,
description: 'What do you want to call this block palette? (e.g. my-block-palette)',
message: 'Must be only letters or dash',
required: true,
@ -39,10 +42,24 @@ void async function main() {
const promptUser = await prompt.get(schema);
const paletteJSON = {
blocks: blocksToUse,
};
log(LogStyle.Info, 'Creating palette...');
const palette = Palette.create();
if (palette === undefined) {
log(LogStyle.Failure, 'Invalid palette name');
return;
}
fs.writeFileSync(path.join(PALETTES_DIR, `./${promptUser.paletteName}.palette`), JSON.stringify(paletteJSON, null, 4));
log(LogStyle.Success, `Successfully created ${promptUser.paletteName}.palette in /resources/palettes/`);
log(LogStyle.Info, 'Adding blocks to palette...');
for (const blockNames of blocksToUse) {
palette.add(blockNames);
}
log(LogStyle.Info, 'Saving palette...');
const success = palette.save(promptUser.paletteName as string);
if (success) {
log(LogStyle.Success, 'Palette saved.');
} else {
log(LogStyle.Failure, 'Could not save palette.');
}
}();