@dice-o-rolla/dice-assets 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -2
- package/THIRD_PARTY_NOTICES.md +3 -0
- package/assets/runtime/catalog.json +649 -0
- package/assets/runtime/previews/diagnostic-d10.svg +1 -0
- package/assets/runtime/previews/diagnostic-d100.svg +1 -0
- package/assets/runtime/previews/diagnostic-d12.svg +1 -0
- package/assets/runtime/previews/diagnostic-d20.svg +1 -0
- package/assets/runtime/previews/diagnostic-d4.svg +1 -0
- package/assets/runtime/previews/diagnostic-d6.svg +1 -0
- package/assets/runtime/previews/diagnostic-d66.svg +1 -0
- package/assets/runtime/previews/diagnostic-d8.svg +1 -0
- package/assets/runtime/textures/diagnostic-d10.ktx2 +0 -0
- package/assets/runtime/textures/diagnostic-d100.ktx2 +0 -0
- package/assets/runtime/textures/diagnostic-d12.ktx2 +0 -0
- package/assets/runtime/textures/diagnostic-d20.ktx2 +0 -0
- package/assets/runtime/textures/diagnostic-d4.ktx2 +0 -0
- package/assets/runtime/textures/diagnostic-d6.ktx2 +0 -0
- package/assets/runtime/textures/diagnostic-d66.ktx2 +0 -0
- package/assets/runtime/textures/diagnostic-d8.ktx2 +0 -0
- package/dist/asset-registry.d.ts +2 -1
- package/dist/asset-registry.js +37 -0
- package/dist/catalog-loader.js +19 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/textured-skin-set.d.ts +32 -0
- package/dist/textured-skin-set.js +115 -0
- package/dist/three-material-provider.d.ts +1 -0
- package/dist/three-material-provider.js +61 -20
- package/dist/tools/build.d.ts +2 -0
- package/dist/tools/build.js +232 -0
- package/dist/tools/cli.d.ts +2 -0
- package/dist/tools/cli.js +63 -0
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/template.d.ts +9 -0
- package/dist/tools/template.js +88 -0
- package/dist/tools/types.d.ts +40 -0
- package/dist/tools/types.js +10 -0
- package/dist/tools/write-directory.d.ts +2 -0
- package/dist/tools/write-directory.js +56 -0
- package/dist/types.d.ts +13 -0
- package/package.json +16 -4
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { getDieGeometry } from '@dice-o-rolla/dice-geometry';
|
|
6
|
+
import { validateSurfaceUvs } from '@dice-o-rolla/dice-renderer-three';
|
|
7
|
+
import { Resvg } from '@resvg/resvg-js';
|
|
8
|
+
import { DOMParser, XMLSerializer } from '@xmldom/xmldom';
|
|
9
|
+
import { DiceAssetRegistry, } from '../index.js';
|
|
10
|
+
import { assertImageSize, assertPortableId, assertTemplateType, geometryType } from './template.js';
|
|
11
|
+
import { writeDirectory } from './write-directory.js';
|
|
12
|
+
const execute = promisify(execFile);
|
|
13
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
function assertSource(value) {
|
|
15
|
+
if (!isRecord(value) ||
|
|
16
|
+
value.schemaVersion !== 1 ||
|
|
17
|
+
typeof value.id !== 'string' ||
|
|
18
|
+
!isRecord(value.dice))
|
|
19
|
+
throw new TypeError('Source manifest requires schemaVersion 1, id and dice');
|
|
20
|
+
assertPortableId(value.id);
|
|
21
|
+
if (value.name !== undefined && typeof value.name !== 'string')
|
|
22
|
+
throw new TypeError('name must be a string');
|
|
23
|
+
if (value.material !== undefined) {
|
|
24
|
+
if (!isRecord(value.material))
|
|
25
|
+
throw new TypeError('material must be an object');
|
|
26
|
+
const material = value.material;
|
|
27
|
+
for (const key of ['roughness', 'metalness'])
|
|
28
|
+
if (typeof material[key] !== 'number' || !Number.isFinite(material[key]))
|
|
29
|
+
throw new TypeError(`material.${key} must be a finite number`);
|
|
30
|
+
for (const key of ['normalScale', 'clearcoat', 'clearcoatRoughness'])
|
|
31
|
+
if (material[key] !== undefined &&
|
|
32
|
+
(typeof material[key] !== 'number' || !Number.isFinite(material[key])))
|
|
33
|
+
throw new TypeError(`material.${key} must be a finite number`);
|
|
34
|
+
if (material.metadata !== undefined &&
|
|
35
|
+
(!isRecord(material.metadata) ||
|
|
36
|
+
!Object.values(material.metadata).every((item) => typeof item === 'string' ||
|
|
37
|
+
typeof item === 'boolean' ||
|
|
38
|
+
(typeof item === 'number' && Number.isFinite(item)))))
|
|
39
|
+
throw new TypeError('Invalid material metadata');
|
|
40
|
+
}
|
|
41
|
+
if (Object.keys(value.dice).length === 0)
|
|
42
|
+
throw new TypeError('Source manifest must contain at least one die');
|
|
43
|
+
for (const [type, entry] of Object.entries(value.dice)) {
|
|
44
|
+
assertTemplateType(type);
|
|
45
|
+
if (!isRecord(entry) ||
|
|
46
|
+
typeof entry.unwrap !== 'string' ||
|
|
47
|
+
entry.unwrap.length === 0 ||
|
|
48
|
+
typeof entry.baseColor !== 'string' ||
|
|
49
|
+
entry.baseColor.length === 0)
|
|
50
|
+
throw new TypeError(`${type} requires unwrap and baseColor paths`);
|
|
51
|
+
for (const map of ['normal', 'orm'])
|
|
52
|
+
if (entry[map] !== undefined && (typeof entry[map] !== 'string' || entry[map].length === 0))
|
|
53
|
+
throw new TypeError(`${type}.${map} must be a path`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function svgWithoutGuides(source) {
|
|
57
|
+
const document = new DOMParser({
|
|
58
|
+
onError: (_level, message) => {
|
|
59
|
+
throw new Error(`Invalid SVG: ${message}`);
|
|
60
|
+
},
|
|
61
|
+
}).parseFromString(source, 'image/svg+xml');
|
|
62
|
+
if (document.doctype !== null || document.documentElement?.localName !== 'svg')
|
|
63
|
+
throw new Error('Expected an SVG document without a doctype');
|
|
64
|
+
const elements = Array.from(document.getElementsByTagName('*'));
|
|
65
|
+
for (const element of elements) {
|
|
66
|
+
if (element.localName === 'script')
|
|
67
|
+
throw new Error('SVG scripts are not supported');
|
|
68
|
+
if (element.getAttribute('id') === 'guides')
|
|
69
|
+
element.parentNode?.removeChild(element);
|
|
70
|
+
const href = element.getAttribute('href') ?? element.getAttribute('xlink:href');
|
|
71
|
+
if (href && !href.startsWith('#') && !/^data:image\/(?:png|jpeg|webp);base64,/i.test(href))
|
|
72
|
+
throw new Error('Embed linked SVG images as PNG/JPEG/WebP data URIs before building');
|
|
73
|
+
}
|
|
74
|
+
return new XMLSerializer().serializeToString(document);
|
|
75
|
+
}
|
|
76
|
+
async function loadImage(path, allowSvg) {
|
|
77
|
+
const bytes = await readFile(path), extension = extname(path).toLowerCase();
|
|
78
|
+
let png, preview;
|
|
79
|
+
if (extension === '.svg' && allowSvg) {
|
|
80
|
+
preview = bytes.toString('utf8');
|
|
81
|
+
const svg = svgWithoutGuides(preview);
|
|
82
|
+
const renderer = new Resvg(svg);
|
|
83
|
+
if (renderer.width !== renderer.height)
|
|
84
|
+
throw new RangeError(`${path}: texture must be square`);
|
|
85
|
+
assertImageSize(renderer.width);
|
|
86
|
+
png = renderer.render().asPng();
|
|
87
|
+
}
|
|
88
|
+
else if (extension === '.png') {
|
|
89
|
+
png = bytes;
|
|
90
|
+
preview = bytes;
|
|
91
|
+
}
|
|
92
|
+
else
|
|
93
|
+
throw new Error(`${path}: expected ${allowSvg ? 'SVG or PNG' : 'PNG'}`);
|
|
94
|
+
if (png.length < 24 || !png.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
|
95
|
+
throw new Error(`${path}: invalid PNG`);
|
|
96
|
+
const size = png.readUInt32BE(16);
|
|
97
|
+
if (size !== png.readUInt32BE(20))
|
|
98
|
+
throw new RangeError(`${path}: texture must be square`);
|
|
99
|
+
assertImageSize(size);
|
|
100
|
+
return { png, size, preview, extension: extension === '.svg' ? 'svg' : 'png' };
|
|
101
|
+
}
|
|
102
|
+
async function runKtx(executable, args) {
|
|
103
|
+
try {
|
|
104
|
+
await execute(executable, [...args], { maxBuffer: 8 * 1024 * 1024 });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (isRecord(error) && error.code === 'ENOENT')
|
|
108
|
+
throw new Error('KTX CLI was not found. Install KTX-Software and put ktx on PATH, or set ktxExecutable.', { cause: error });
|
|
109
|
+
throw new Error(`KTX ${args[0]} failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export async function buildTexturedSkinSet(options) {
|
|
113
|
+
const input = resolve(options.input), output = resolve(options.outputDirectory);
|
|
114
|
+
const toInput = relative(output, input);
|
|
115
|
+
if (!isAbsolute(toInput) &&
|
|
116
|
+
toInput !== '..' &&
|
|
117
|
+
!toInput.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`))
|
|
118
|
+
throw new Error('Keep the source manifest outside the output directory');
|
|
119
|
+
const source = JSON.parse(await readFile(input, 'utf8'));
|
|
120
|
+
assertSource(source);
|
|
121
|
+
const root = dirname(input);
|
|
122
|
+
const material = {
|
|
123
|
+
...source.material,
|
|
124
|
+
roughness: source.material?.roughness ?? 0.7,
|
|
125
|
+
metalness: source.material?.metalness ?? 0,
|
|
126
|
+
id: `${source.id}-material`,
|
|
127
|
+
};
|
|
128
|
+
const prepared = await Promise.all(Object.entries(source.dice).map(async ([rawType, entry]) => {
|
|
129
|
+
assertTemplateType(rawType);
|
|
130
|
+
const raw = JSON.parse(await readFile(resolve(root, entry.unwrap), 'utf8'));
|
|
131
|
+
if (!isRecord(raw) || raw.geometryId !== geometryType(rawType) || !isRecord(raw.faces))
|
|
132
|
+
throw new Error(`${rawType}: unwrap geometry must be ${geometryType(rawType)}`);
|
|
133
|
+
const faces = {};
|
|
134
|
+
for (const [key, coordinates] of Object.entries(raw.faces)) {
|
|
135
|
+
if (!/^[1-9]\d*$/.test(key) || !Array.isArray(coordinates))
|
|
136
|
+
throw new TypeError(`${rawType}: invalid face ${key}`);
|
|
137
|
+
faces[Number(key)] = coordinates.map((uv) => {
|
|
138
|
+
if (!Array.isArray(uv) ||
|
|
139
|
+
uv.length !== 2 ||
|
|
140
|
+
typeof uv[0] !== 'number' ||
|
|
141
|
+
typeof uv[1] !== 'number')
|
|
142
|
+
throw new TypeError(`${rawType}: invalid UV on face ${key}`);
|
|
143
|
+
return [uv[0], uv[1]];
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const unwrap = { geometryId: geometryType(rawType), faces };
|
|
147
|
+
validateSurfaceUvs(getDieGeometry(geometryType(rawType)), unwrap.faces);
|
|
148
|
+
const [baseColor, normal, orm] = await Promise.all([
|
|
149
|
+
loadImage(resolve(root, entry.baseColor), true),
|
|
150
|
+
entry.normal === undefined ? undefined : loadImage(resolve(root, entry.normal), false),
|
|
151
|
+
entry.orm === undefined ? undefined : loadImage(resolve(root, entry.orm), false),
|
|
152
|
+
]);
|
|
153
|
+
if ((normal !== undefined && normal.size !== baseColor.size) ||
|
|
154
|
+
(orm !== undefined && orm.size !== baseColor.size))
|
|
155
|
+
throw new Error(`${rawType}: baseColor, normal and ORM dimensions must match`);
|
|
156
|
+
return { type: rawType, unwrap, baseColor, normal, orm };
|
|
157
|
+
}));
|
|
158
|
+
const catalog = {
|
|
159
|
+
schemaVersion: 1,
|
|
160
|
+
materials: [material],
|
|
161
|
+
patterns: prepared.map((die) => ({
|
|
162
|
+
id: `${source.id}-${die.type}`,
|
|
163
|
+
baseColor: texture(die.type, 'baseColor'),
|
|
164
|
+
...(die.normal === undefined ? {} : { normal: texture(die.type, 'normal') }),
|
|
165
|
+
...(die.orm === undefined ? {} : { orm: texture(die.type, 'orm') }),
|
|
166
|
+
unwrap: {
|
|
167
|
+
geometryId: die.unwrap.geometryId,
|
|
168
|
+
faces: die.unwrap.faces,
|
|
169
|
+
preview: {
|
|
170
|
+
uri: `./previews/${die.type}.${die.baseColor.extension}`,
|
|
171
|
+
mediaType: die.baseColor.extension === 'svg' ? 'image/svg+xml' : 'image/png',
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
})),
|
|
175
|
+
skins: prepared.map((die) => ({
|
|
176
|
+
id: `${source.id}-${die.type}`,
|
|
177
|
+
materialId: material.id,
|
|
178
|
+
patternId: `${source.id}-${die.type}`,
|
|
179
|
+
})),
|
|
180
|
+
skinSets: [
|
|
181
|
+
{
|
|
182
|
+
id: source.id,
|
|
183
|
+
...(source.name === undefined ? {} : { name: source.name }),
|
|
184
|
+
skins: Object.fromEntries(prepared.map((die) => [die.type, `${source.id}-${die.type}`])),
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
};
|
|
188
|
+
new DiceAssetRegistry().registerCatalog(catalog);
|
|
189
|
+
const executable = options.ktxExecutable ?? 'ktx';
|
|
190
|
+
await writeDirectory(output, options.overwrite ?? false, async (staging) => {
|
|
191
|
+
await Promise.all(['textures', 'previews', '.intermediate'].map((directory) => mkdir(join(staging, directory))));
|
|
192
|
+
await prepared.reduce(async (previous, die) => {
|
|
193
|
+
await previous;
|
|
194
|
+
await writeFile(join(staging, 'previews', `${die.type}.${die.baseColor.extension}`), die.baseColor.preview);
|
|
195
|
+
await ['baseColor', 'normal', 'orm'].reduce(async (prior, map) => {
|
|
196
|
+
await prior;
|
|
197
|
+
const image = die[map];
|
|
198
|
+
if (image === undefined)
|
|
199
|
+
return;
|
|
200
|
+
const pngPath = join(staging, '.intermediate', `${die.type}-${map}.png`), outputPath = join(staging, 'textures', `${die.type}-${map}.ktx2`);
|
|
201
|
+
await writeFile(pngPath, image.png);
|
|
202
|
+
await runKtx(executable, [
|
|
203
|
+
'create',
|
|
204
|
+
'--format',
|
|
205
|
+
map === 'baseColor' ? 'R8G8B8A8_SRGB' : 'R8G8B8A8_UNORM',
|
|
206
|
+
'--assign-tf',
|
|
207
|
+
map === 'baseColor' ? 'srgb' : 'linear',
|
|
208
|
+
'--convert-texcoord-origin',
|
|
209
|
+
'bottom-left',
|
|
210
|
+
'--encode',
|
|
211
|
+
'uastc-ldr-4x4',
|
|
212
|
+
'--generate-mipmap',
|
|
213
|
+
'--zstd',
|
|
214
|
+
'12',
|
|
215
|
+
pngPath,
|
|
216
|
+
outputPath,
|
|
217
|
+
]);
|
|
218
|
+
await runKtx(executable, ['validate', outputPath]);
|
|
219
|
+
}, Promise.resolve());
|
|
220
|
+
}, Promise.resolve());
|
|
221
|
+
await writeFile(join(staging, 'catalog.json'), `${JSON.stringify(catalog, null, 2)}\n`);
|
|
222
|
+
const { rm } = await import('node:fs/promises');
|
|
223
|
+
await rm(join(staging, '.intermediate'), { recursive: true });
|
|
224
|
+
});
|
|
225
|
+
return { outputDirectory: output, catalog };
|
|
226
|
+
}
|
|
227
|
+
const texture = (type, map) => ({
|
|
228
|
+
uri: `./textures/${type}-${map}.ktx2`,
|
|
229
|
+
mediaType: 'image/ktx2',
|
|
230
|
+
colorSpace: map === 'baseColor' ? 'srgb' : 'linear',
|
|
231
|
+
mipmaps: true,
|
|
232
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { buildTexturedSkinSet } from './build.js';
|
|
4
|
+
import { assertTemplateType, writeTextureTemplates } from './template.js';
|
|
5
|
+
import { TEXTURE_TEMPLATE_TYPES } from './types.js';
|
|
6
|
+
async function main() {
|
|
7
|
+
const { values, positionals } = parseArgs({
|
|
8
|
+
options: {
|
|
9
|
+
help: { type: 'boolean' },
|
|
10
|
+
types: { type: 'string' },
|
|
11
|
+
out: { type: 'string' },
|
|
12
|
+
input: { type: 'string' },
|
|
13
|
+
id: { type: 'string' },
|
|
14
|
+
size: { type: 'string' },
|
|
15
|
+
overwrite: { type: 'boolean' },
|
|
16
|
+
ktx: { type: 'string' },
|
|
17
|
+
},
|
|
18
|
+
allowPositionals: true,
|
|
19
|
+
});
|
|
20
|
+
if (values.help || positionals.length === 0) {
|
|
21
|
+
console.log('dice-assets template --types d6|standard|d4,d6 --out DIR [--id custom] [--size 2048]\ndice-assets build --input skin-set.source.json --out DIR [--ktx PATH]\nUse --overwrite to replace the entire destination directory after success.');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (positionals.length !== 1 || values.out === undefined)
|
|
25
|
+
throw new Error('Specify one command and --out DIR; see --help');
|
|
26
|
+
const shared = { outputDirectory: values.out, overwrite: values.overwrite ?? false };
|
|
27
|
+
if (positionals[0] === 'template') {
|
|
28
|
+
if (values.input !== undefined || values.ktx !== undefined)
|
|
29
|
+
throw new Error('--input and --ktx are build options');
|
|
30
|
+
const types = values.types === undefined || values.types === 'standard'
|
|
31
|
+
? TEXTURE_TEMPLATE_TYPES
|
|
32
|
+
: values.types.split(',').map((value) => {
|
|
33
|
+
assertTemplateType(value);
|
|
34
|
+
return value;
|
|
35
|
+
});
|
|
36
|
+
await writeTextureTemplates({
|
|
37
|
+
...shared,
|
|
38
|
+
types,
|
|
39
|
+
...(values.id === undefined ? {} : { id: values.id }),
|
|
40
|
+
...(values.size === undefined ? {} : { size: Number(values.size) }),
|
|
41
|
+
});
|
|
42
|
+
console.log(`Templates written to ${values.out}`);
|
|
43
|
+
}
|
|
44
|
+
else if (positionals[0] === 'build') {
|
|
45
|
+
if (values.input === undefined ||
|
|
46
|
+
values.types !== undefined ||
|
|
47
|
+
values.id !== undefined ||
|
|
48
|
+
values.size !== undefined)
|
|
49
|
+
throw new Error('build requires --input and does not accept template options');
|
|
50
|
+
await buildTexturedSkinSet({
|
|
51
|
+
...shared,
|
|
52
|
+
input: values.input,
|
|
53
|
+
...(values.ktx === undefined ? {} : { ktxExecutable: values.ktx }),
|
|
54
|
+
});
|
|
55
|
+
console.log(`Catalog written to ${values.out}/catalog.json`);
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
throw new Error(`Unknown command: ${positionals[0]}`);
|
|
59
|
+
}
|
|
60
|
+
void main().catch((error) => {
|
|
61
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createTextureTemplate, writeTextureTemplates } from './template.js';
|
|
2
|
+
export { buildTexturedSkinSet } from './build.js';
|
|
3
|
+
export { TEXTURE_TEMPLATE_TYPES, type TextureTemplateType, type TextureTemplate, type TextureSourceEntry, type TexturedSkinSetSource, type WriteTextureTemplatesOptions, type BuildTexturedSkinSetOptions, type BuiltTexturedSkinSet, } from './types.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type TextureTemplate, type TextureTemplateType, type TexturedSkinSetSource, type WriteTextureTemplatesOptions } from './types.js';
|
|
2
|
+
export declare function geometryType(type: TextureTemplateType): "d10" | "d12" | "d20" | "d4" | "d6" | "d8";
|
|
3
|
+
export declare function assertImageSize(size: number): void;
|
|
4
|
+
export declare function assertTemplateType(type: string): asserts type is TextureTemplateType;
|
|
5
|
+
export declare function assertPortableId(id: string): void;
|
|
6
|
+
export declare function createTextureTemplate(type: TextureTemplateType, options?: {
|
|
7
|
+
readonly size?: number;
|
|
8
|
+
}): TextureTemplate;
|
|
9
|
+
export declare function writeTextureTemplates(options: WriteTextureTemplatesOptions): Promise<TexturedSkinSetSource>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createStandardDiceNet, getDieGeometry } from '@dice-o-rolla/dice-geometry';
|
|
4
|
+
import { getFaceLabel } from '@dice-o-rolla/dice-renderer-three';
|
|
5
|
+
import { TEXTURE_TEMPLATE_TYPES, } from './types.js';
|
|
6
|
+
import { writeDirectory } from './write-directory.js';
|
|
7
|
+
export function geometryType(type) {
|
|
8
|
+
return type === 'd100' ? 'd10' : type === 'd66' ? 'd6' : type;
|
|
9
|
+
}
|
|
10
|
+
export function assertImageSize(size) {
|
|
11
|
+
if (!Number.isSafeInteger(size) || size < 64 || size > 8192 || (size & (size - 1)) !== 0)
|
|
12
|
+
throw new RangeError('Texture size must be a power of two between 64 and 8192');
|
|
13
|
+
}
|
|
14
|
+
export function assertTemplateType(type) {
|
|
15
|
+
if (!TEXTURE_TEMPLATE_TYPES.some((value) => value === type))
|
|
16
|
+
throw new RangeError(`Unsupported dice texture type: ${type}`);
|
|
17
|
+
}
|
|
18
|
+
export function assertPortableId(id) {
|
|
19
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(id))
|
|
20
|
+
throw new RangeError('Skin set id must contain only letters, digits, underscores and hyphens');
|
|
21
|
+
}
|
|
22
|
+
export function createTextureTemplate(type, options = {}) {
|
|
23
|
+
assertTemplateType(type);
|
|
24
|
+
const size = options.size ?? 2048;
|
|
25
|
+
assertImageSize(size);
|
|
26
|
+
const unwrap = createStandardDiceNet(geometryType(type));
|
|
27
|
+
const geometry = getDieGeometry(geometryType(type));
|
|
28
|
+
const artwork = [], labels = [], guides = [];
|
|
29
|
+
for (const face of geometry.faces) {
|
|
30
|
+
const points = unwrap.faces[face.value].map(([u, v]) => [u * size, (1 - v) * size]);
|
|
31
|
+
const polygon = points.map((point) => point.join(',')).join(' ');
|
|
32
|
+
const cx = points.reduce((sum, p) => sum + p[0], 0) / points.length, cy = points.reduce((sum, p) => sum + p[1], 0) / points.length;
|
|
33
|
+
const radius = Math.min(...points.map((p) => Math.hypot(p[0] - cx, p[1] - cy)));
|
|
34
|
+
artwork.push(`<polygon id="art-face-${face.value}" points="${polygon}" fill="#e0e6ed" stroke="#e0e6ed" stroke-width="${size / 128}" stroke-linejoin="round"/>`);
|
|
35
|
+
guides.push(`<polygon points="${polygon}" fill="none" stroke="#d72b65" stroke-width="${size / 1024}"/><text x="${cx}" y="${cy - radius * 0.3}" font-size="${radius * 0.09}" text-anchor="middle">${type} / face ${face.value}</text>`);
|
|
36
|
+
for (let edge = 0; edge < points.length; edge++) {
|
|
37
|
+
const a = points[edge], b = points[(edge + 1) % points.length];
|
|
38
|
+
const av = face.indices[edge], bv = face.indices[(edge + 1) % points.length];
|
|
39
|
+
guides.push(`<text x="${(a[0] + b[0]) * 0.43 + cx * 0.14}" y="${(a[1] + b[1]) * 0.43 + cy * 0.14}" font-size="${radius * 0.08}" text-anchor="middle">${Math.min(av, bv)}:${Math.max(av, bv)}</text>`);
|
|
40
|
+
}
|
|
41
|
+
const label = getFaceLabel(geometry, face);
|
|
42
|
+
if (typeof label === 'string' || typeof label === 'number') {
|
|
43
|
+
const display = type === 'd100'
|
|
44
|
+
? face.value === 10
|
|
45
|
+
? '00'
|
|
46
|
+
: String(face.value * 10)
|
|
47
|
+
: type === 'd66'
|
|
48
|
+
? String(face.value * 10)
|
|
49
|
+
: String(label);
|
|
50
|
+
labels.push(`<text x="${cx}" y="${cy}" font-size="${radius * 0.5}" text-anchor="middle" dominant-baseline="central">${display}</text>`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
for (const [index, value] of label.entries()) {
|
|
54
|
+
const point = points[index];
|
|
55
|
+
labels.push(`<text x="${(cx + point[0]) / 2}" y="${(cy + point[1]) / 2}" font-size="${radius * 0.24}" text-anchor="middle" dominant-baseline="central">${value}</text>`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" font-family="sans-serif" fill="#172535">${layer('artwork', artwork)}${layer('labels', labels)}${layer('guides', guides)}</svg>\n`;
|
|
60
|
+
return { type, unwrap, svg };
|
|
61
|
+
}
|
|
62
|
+
export async function writeTextureTemplates(options) {
|
|
63
|
+
const id = options.id ?? 'custom';
|
|
64
|
+
assertPortableId(id);
|
|
65
|
+
const types = options.types ?? TEXTURE_TEMPLATE_TYPES;
|
|
66
|
+
if (types.length === 0 || new Set(types).size !== types.length)
|
|
67
|
+
throw new RangeError('Choose at least one distinct dice type');
|
|
68
|
+
const templates = types.map((type) => createTextureTemplate(type, options.size === undefined ? {} : { size: options.size }));
|
|
69
|
+
const manifest = {
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
id,
|
|
72
|
+
name: id,
|
|
73
|
+
material: { roughness: 0.7, metalness: 0 },
|
|
74
|
+
dice: Object.fromEntries(templates.map((template) => [
|
|
75
|
+
template.type,
|
|
76
|
+
{ unwrap: `${template.type}.uv.json`, baseColor: `${template.type}.svg` },
|
|
77
|
+
])),
|
|
78
|
+
};
|
|
79
|
+
await writeDirectory(options.outputDirectory, options.overwrite ?? false, async (directory) => {
|
|
80
|
+
await Promise.all(templates.flatMap((template) => [
|
|
81
|
+
writeFile(join(directory, `${template.type}.svg`), template.svg),
|
|
82
|
+
writeFile(join(directory, `${template.type}.uv.json`), `${JSON.stringify(template.unwrap, null, 2)}\n`),
|
|
83
|
+
]));
|
|
84
|
+
await writeFile(join(directory, 'skin-set.source.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
85
|
+
});
|
|
86
|
+
return manifest;
|
|
87
|
+
}
|
|
88
|
+
const layer = (id, contents) => `<g id="${id}" inkscape:groupmode="layer" inkscape:label="${id}">${contents.join('')}</g>`;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { DiceAssetCatalogManifest, DiceMaterialDefinition, DiceSurfaceUnwrap } from '../index.js';
|
|
2
|
+
export declare const TEXTURE_TEMPLATE_TYPES: readonly ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100', 'd66'];
|
|
3
|
+
export type TextureTemplateType = (typeof TEXTURE_TEMPLATE_TYPES)[number];
|
|
4
|
+
export interface TextureTemplate {
|
|
5
|
+
readonly type: TextureTemplateType;
|
|
6
|
+
readonly unwrap: DiceSurfaceUnwrap;
|
|
7
|
+
readonly svg: string;
|
|
8
|
+
}
|
|
9
|
+
export interface TextureSourceEntry {
|
|
10
|
+
readonly unwrap: string;
|
|
11
|
+
readonly baseColor: string;
|
|
12
|
+
readonly normal?: string;
|
|
13
|
+
readonly orm?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface TexturedSkinSetSource {
|
|
16
|
+
readonly schemaVersion: 1;
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly name?: string;
|
|
19
|
+
readonly material?: Omit<DiceMaterialDefinition, 'id'>;
|
|
20
|
+
readonly dice: Partial<Readonly<Record<TextureTemplateType, TextureSourceEntry>>>;
|
|
21
|
+
}
|
|
22
|
+
export interface WriteTextureTemplatesOptions {
|
|
23
|
+
readonly outputDirectory: string;
|
|
24
|
+
readonly types?: readonly TextureTemplateType[];
|
|
25
|
+
readonly id?: string;
|
|
26
|
+
readonly size?: number;
|
|
27
|
+
/** Replace the entire output directory after successful preparation. */
|
|
28
|
+
readonly overwrite?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface BuildTexturedSkinSetOptions {
|
|
31
|
+
readonly input: string;
|
|
32
|
+
readonly outputDirectory: string;
|
|
33
|
+
readonly ktxExecutable?: string;
|
|
34
|
+
/** Replace the entire output directory only after a successful build. */
|
|
35
|
+
readonly overwrite?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface BuiltTexturedSkinSet {
|
|
38
|
+
readonly outputDirectory: string;
|
|
39
|
+
readonly catalog: DiceAssetCatalogManifest;
|
|
40
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { lstat, mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
3
|
+
/** Stage beside the destination so the final rename stays on the same filesystem. */
|
|
4
|
+
export async function writeDirectory(destination, overwrite, write) {
|
|
5
|
+
const output = resolve(destination), parent = dirname(output);
|
|
6
|
+
if (output === parent)
|
|
7
|
+
throw new Error('Output must not be a filesystem root');
|
|
8
|
+
await mkdir(parent, { recursive: true });
|
|
9
|
+
const lock = `${output}.dice-assets-lock`;
|
|
10
|
+
try {
|
|
11
|
+
await mkdir(lock);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error(`Output is locked: ${output}`);
|
|
15
|
+
}
|
|
16
|
+
let staging, backup;
|
|
17
|
+
try {
|
|
18
|
+
const existing = await lstat(output).catch((error) => {
|
|
19
|
+
if (isMissing(error))
|
|
20
|
+
return undefined;
|
|
21
|
+
throw error;
|
|
22
|
+
});
|
|
23
|
+
if (existing !== undefined &&
|
|
24
|
+
(!overwrite || !existing.isDirectory() || existing.isSymbolicLink()))
|
|
25
|
+
throw new Error(`Output already exists; use overwrite only for a disposable output directory: ${output}`);
|
|
26
|
+
staging = await mkdtemp(resolve(parent, `.${basename(output)}-staging-`));
|
|
27
|
+
await write(staging);
|
|
28
|
+
if (existing !== undefined) {
|
|
29
|
+
backup = `${staging}-previous`;
|
|
30
|
+
await rename(output, backup);
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await rename(staging, output);
|
|
34
|
+
staging = undefined;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (backup !== undefined) {
|
|
38
|
+
await rename(backup, output);
|
|
39
|
+
backup = undefined;
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
if (backup !== undefined) {
|
|
44
|
+
await rm(backup, { recursive: true, force: true });
|
|
45
|
+
backup = undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
if (staging !== undefined)
|
|
50
|
+
await rm(staging, { recursive: true, force: true });
|
|
51
|
+
await rm(lock, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function isMissing(error) {
|
|
55
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
56
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
export interface DiceSurfaceUnwrap {
|
|
2
|
+
readonly geometryId: string;
|
|
3
|
+
/** Normalized UVs (origin bottom-left), in each geometry face's vertex order. */
|
|
4
|
+
readonly faces: Readonly<Record<number, readonly (readonly [u: number, v: number])[]>>;
|
|
5
|
+
readonly preview?: DiceAssetReference;
|
|
6
|
+
}
|
|
7
|
+
export interface DiceSkinSetDefinition {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly name?: string;
|
|
10
|
+
readonly skins: Readonly<Record<string, string>>;
|
|
11
|
+
}
|
|
1
12
|
export type DiceAssetMetadataValue = string | number | boolean;
|
|
2
13
|
export interface DiceAssetReference {
|
|
3
14
|
readonly uri: string;
|
|
@@ -46,6 +57,7 @@ export interface DiceMaterialDefinition {
|
|
|
46
57
|
readonly metadata?: Readonly<Record<string, DiceAssetMetadataValue>>;
|
|
47
58
|
}
|
|
48
59
|
export interface DicePatternDefinition {
|
|
60
|
+
readonly unwrap?: DiceSurfaceUnwrap;
|
|
49
61
|
readonly id: string;
|
|
50
62
|
readonly baseColor: RuntimeTextureReference;
|
|
51
63
|
readonly normal?: RuntimeTextureReference;
|
|
@@ -85,6 +97,7 @@ export interface DiceSkinDefinition {
|
|
|
85
97
|
}
|
|
86
98
|
export interface DiceAssetCatalogManifest {
|
|
87
99
|
readonly schemaVersion: 1;
|
|
100
|
+
readonly skinSets?: readonly DiceSkinSetDefinition[];
|
|
88
101
|
readonly audioSprites?: readonly AudioSpriteManifest[];
|
|
89
102
|
readonly audioBanks?: readonly AudioBankDefinition[];
|
|
90
103
|
readonly materials?: readonly DiceMaterialDefinition[];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dice-o-rolla/dice-assets",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "Web Audio and KTX2/PBR assets with texture authoring API and CLI for Dice O Rolla.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"assets",
|
|
7
7
|
"dice",
|
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
"url": "https://github.com/creepiest-space/dice-o-rolla.git",
|
|
20
20
|
"directory": "packages/dice-assets"
|
|
21
21
|
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"dice-assets": "./dist/tools/cli.js"
|
|
24
|
+
},
|
|
22
25
|
"files": [
|
|
23
26
|
"dist",
|
|
24
27
|
"assets/runtime",
|
|
@@ -36,15 +39,24 @@
|
|
|
36
39
|
"import": "./dist/index.js",
|
|
37
40
|
"default": "./dist/index.js"
|
|
38
41
|
},
|
|
39
|
-
"./catalog.json": "./assets/runtime/catalog.json"
|
|
42
|
+
"./catalog.json": "./assets/runtime/catalog.json",
|
|
43
|
+
"./tools": {
|
|
44
|
+
"types": "./dist/tools/index.d.ts",
|
|
45
|
+
"import": "./dist/tools/index.js",
|
|
46
|
+
"default": "./dist/tools/index.js"
|
|
47
|
+
}
|
|
40
48
|
},
|
|
41
49
|
"publishConfig": {
|
|
42
50
|
"access": "public",
|
|
43
51
|
"registry": "https://registry.npmjs.org/"
|
|
44
52
|
},
|
|
45
53
|
"dependencies": {
|
|
46
|
-
"@dice-o-rolla/dice-
|
|
54
|
+
"@dice-o-rolla/dice-geometry": "0.5.0",
|
|
55
|
+
"@dice-o-rolla/dice-renderer": "0.5.0",
|
|
56
|
+
"@dice-o-rolla/dice-renderer-three": "0.5.0",
|
|
57
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
47
58
|
"@types/three": "^0.185.0",
|
|
59
|
+
"@xmldom/xmldom": "^0.9.12",
|
|
48
60
|
"three": "^0.185.0"
|
|
49
61
|
},
|
|
50
62
|
"engines": {
|