forked from mirror/ObjToSchematic
Refactored atlas and palette loading and handling
This commit is contained in:
parent
b16f253124
commit
0f46c16ccc
128
src/atlas.ts
Normal file
128
src/atlas.ts
Normal 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`);
|
||||
}
|
||||
}
|
@ -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
154
src/palette.ts
Normal 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`);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
21
src/util.ts
21
src/util.ts
@ -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;
|
||||
|
@ -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.');
|
||||
}
|
||||
}();
|
||||
|
Loading…
Reference in New Issue
Block a user