@feelgxxd/textel 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +180 -2
- package/dist/ansi.d.ts +13 -0
- package/dist/ansi.js +46 -0
- package/dist/browser/player-core.d.ts +23 -0
- package/dist/browser/player-core.js +68 -0
- package/dist/browser/player.d.ts +25 -0
- package/dist/browser/player.js +198 -0
- package/dist/cli/commands/list.d.ts +3 -0
- package/dist/cli/commands/list.js +21 -0
- package/dist/cli/commands/play.d.ts +3 -0
- package/dist/cli/commands/play.js +55 -0
- package/dist/cli/commands/preview.d.ts +3 -0
- package/dist/cli/commands/preview.js +57 -0
- package/dist/cli/commands/render.d.ts +3 -0
- package/dist/cli/commands/render.js +94 -0
- package/dist/cli/live.d.ts +13 -0
- package/dist/cli/live.js +116 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +53 -0
- package/dist/cli/shared.d.ts +103 -0
- package/dist/cli/shared.js +178 -0
- package/dist/examples.d.ts +13 -0
- package/dist/examples.js +164 -0
- package/dist/format.d.ts +57 -0
- package/dist/format.js +262 -0
- package/dist/frames.d.ts +34 -0
- package/dist/frames.js +88 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/loaders/gltf.d.ts +7 -0
- package/dist/loaders/gltf.js +187 -0
- package/dist/loaders/index.d.ts +8 -0
- package/dist/loaders/index.js +24 -0
- package/dist/loaders/obj.d.ts +3 -0
- package/dist/loaders/obj.js +52 -0
- package/dist/loaders/stl.d.ts +3 -0
- package/dist/loaders/stl.js +29 -0
- package/dist/mesh.d.ts +35 -0
- package/dist/mesh.js +106 -0
- package/dist/output.d.ts +18 -0
- package/dist/output.js +76 -0
- package/dist/render.d.ts +36 -0
- package/dist/render.js +305 -0
- package/dist/sdf.d.ts +36 -0
- package/dist/sdf.js +151 -0
- package/dist/style.d.ts +125 -0
- package/dist/style.js +130 -0
- package/dist/terminal.d.ts +89 -0
- package/dist/terminal.js +197 -0
- package/dist/textel-player.js +7 -0
- package/package.json +84 -4
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const summary = "spin a model in the terminal (the default command)";
|
|
2
|
+
export declare const HELP = "textel preview: spin a model in the terminal\n\nusage\n textel [preview] <model> [options]\n textel [preview] --example <name> [options]\n\ninput\n <model> a .glb, .gltf, .obj or .stl file\n -e, --example <name> a built-in scene instead (textel list)\n\nlook\n -c, --chars <ramp|name> charset name or a custom ramp, darkest first\n -p, --preset <file.json> style file; flags override it\n --color <#hex> one color; --color #301000,#ffb000 for a gradient\n --no-color plain characters\n --edges [depth] outline the silhouette; depth also marks depth steps; off\n --light <x,y,z> direction toward the light (x right, y up, z toward you)\n --ambient <0..1> brightness floor\n --shading <mode> auto | flat | smooth\n\ncamera\n --pitch <rad> resting tilt; positive shows the top\n --spin <rad/s> auto rotation speed (0 = still)\n --zoom <n> 1 fills the grid\n --distance <n> camera distance in model radii (perspective)\n -s, --size <WxH> grid size\n -a, --aspect <n> character height / width (default 2)\n\noutput\n --once print one frame and exit (also when output is not a terminal)\n --yaw <rad> rotation of that frame (default 0.6)\n --fps <n> frame rate while it runs (default 30)\n\nkeys \u2190\u2192\u2191\u2193 rotate +/- zoom c chars e edges l light space pause r reset q quit\n";
|
|
3
|
+
export declare function run(argv: string[]): undefined;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { toANSI, toText } from "../../output.js";
|
|
2
|
+
import { createRenderer } from "../../render.js";
|
|
3
|
+
import { live } from "../live.js";
|
|
4
|
+
import { colorDepth, fail, HELP_OPTIONS, INPUT_HELP, INPUT_OPTIONS, loadInput, num, parse, parseSize, STYLE_HELP, STYLE_OPTIONS, styleFrom } from "../shared.js";
|
|
5
|
+
export const summary = 'spin a model in the terminal (the default command)';
|
|
6
|
+
export const HELP = `textel preview: spin a model in the terminal
|
|
7
|
+
|
|
8
|
+
usage
|
|
9
|
+
textel [preview] <model> [options]
|
|
10
|
+
textel [preview] --example <name> [options]
|
|
11
|
+
|
|
12
|
+
${INPUT_HELP}
|
|
13
|
+
|
|
14
|
+
${STYLE_HELP}
|
|
15
|
+
|
|
16
|
+
output
|
|
17
|
+
--once print one frame and exit (also when output is not a terminal)
|
|
18
|
+
--yaw <rad> rotation of that frame (default 0.6)
|
|
19
|
+
--fps <n> frame rate while it runs (default 30)
|
|
20
|
+
|
|
21
|
+
keys ←→↑↓ rotate +/- zoom c chars e edges l light space pause r reset q quit
|
|
22
|
+
`;
|
|
23
|
+
const OPTIONS = {
|
|
24
|
+
...INPUT_OPTIONS,
|
|
25
|
+
...STYLE_OPTIONS,
|
|
26
|
+
...HELP_OPTIONS,
|
|
27
|
+
once: { type: 'boolean' },
|
|
28
|
+
yaw: { type: 'string' },
|
|
29
|
+
fps: { type: 'string' },
|
|
30
|
+
};
|
|
31
|
+
export function run(argv) {
|
|
32
|
+
const { values: o, positionals } = parse(argv, OPTIONS);
|
|
33
|
+
if (o.help)
|
|
34
|
+
return void process.stdout.write(HELP);
|
|
35
|
+
if (positionals.length > 1)
|
|
36
|
+
fail(`unexpected argument "${positionals[1]}"`);
|
|
37
|
+
const input = loadInput(o, positionals);
|
|
38
|
+
if (!input)
|
|
39
|
+
return void process.stdout.write(HELP);
|
|
40
|
+
const style = styleFrom(o, input.style);
|
|
41
|
+
const depth = colorDepth(o['no-color']);
|
|
42
|
+
const tty = !!process.stdout.isTTY;
|
|
43
|
+
if (o.once || !tty || !process.stdin.isTTY) {
|
|
44
|
+
const [w, h] = style.width && style.height ? [style.width, style.height] : tty ? [process.stdout.columns, Math.max(5, process.stdout.rows - 1)] : [80, 40];
|
|
45
|
+
let r;
|
|
46
|
+
try {
|
|
47
|
+
r = createRenderer(input.mesh, { ...style, width: w, height: h });
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
fail(e.message);
|
|
51
|
+
}
|
|
52
|
+
const frame = r.render({ yaw: num(o.yaw, '--yaw') ?? 0.6 });
|
|
53
|
+
process.stdout.write((depth === 'none' ? toText(frame) : toANSI(frame, depth).join('\n')) + '\n');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
live({ mesh: input.mesh, name: input.name, style, fixedSize: parseSize(o.size), depth, fps: num(o.fps, '--fps') ?? 30 });
|
|
57
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const summary = "record a spinning loop into a .textel.json file";
|
|
2
|
+
export declare const HELP = "textel render: record a spinning loop into a .textel.json file\n\nusage\n textel render <model> [-o out.textel.json] [options]\n textel render --example <name> [options]\n\ninput\n <model> a .glb, .gltf, .obj or .stl file\n -e, --example <name> a built-in scene instead (textel list)\n\nrecording\n -o, --out <file> output file (default: <name>.textel.json here)\n --frames <n> frames in the loop (default 120)\n --fps <n> playback rate (default 30)\n --turns <n> turns over the loop; negative spins the other way (default 1)\n --force overwrite an existing file\n\nlook\n -c, --chars <ramp|name> charset name or a custom ramp, darkest first\n -p, --preset <file.json> style file; flags override it\n --color <#hex> one color; --color #301000,#ffb000 for a gradient\n --no-color plain characters\n --edges [depth] outline the silhouette; depth also marks depth steps; off\n --light <x,y,z> direction toward the light (x right, y up, z toward you)\n --ambient <0..1> brightness floor\n --shading <mode> auto | flat | smooth\n\ncamera\n --pitch <rad> resting tilt; positive shows the top\n --spin <rad/s> auto rotation speed (0 = still)\n --zoom <n> 1 fills the grid\n --distance <n> camera distance in model radii (perspective)\n -s, --size <WxH> grid size\n -a, --aspect <n> character height / width (default 2)\n\n the grid defaults to 112x56 at aspect 2 (terminals); for a web page pass the\n aspect of its font, for example --aspect 1.5\n";
|
|
3
|
+
export declare function run(argv: string[]): undefined;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, extname, resolve } from 'node:path';
|
|
3
|
+
import { gzipSync } from 'node:zlib';
|
|
4
|
+
import { recordLoop, stringifyFrames } from "../../frames.js";
|
|
5
|
+
import { createRenderer } from "../../render.js";
|
|
6
|
+
import { mergeStyles } from "../../style.js";
|
|
7
|
+
import { fail, HELP_OPTIONS, INPUT_HELP, INPUT_OPTIONS, loadInput, num, parse, STYLE_HELP, STYLE_OPTIONS, styleFrom } from "../shared.js";
|
|
8
|
+
export const summary = 'record a spinning loop into a .textel.json file';
|
|
9
|
+
export const HELP = `textel render: record a spinning loop into a .textel.json file
|
|
10
|
+
|
|
11
|
+
usage
|
|
12
|
+
textel render <model> [-o out.textel.json] [options]
|
|
13
|
+
textel render --example <name> [options]
|
|
14
|
+
|
|
15
|
+
${INPUT_HELP}
|
|
16
|
+
|
|
17
|
+
recording
|
|
18
|
+
-o, --out <file> output file (default: <name>.textel.json here)
|
|
19
|
+
--frames <n> frames in the loop (default 120)
|
|
20
|
+
--fps <n> playback rate (default 30)
|
|
21
|
+
--turns <n> turns over the loop; negative spins the other way (default 1)
|
|
22
|
+
--force overwrite an existing file
|
|
23
|
+
|
|
24
|
+
${STYLE_HELP}
|
|
25
|
+
|
|
26
|
+
the grid defaults to 112x56 at aspect 2 (terminals); for a web page pass the
|
|
27
|
+
aspect of its font, for example --aspect 1.5
|
|
28
|
+
`;
|
|
29
|
+
const OPTIONS = {
|
|
30
|
+
...INPUT_OPTIONS,
|
|
31
|
+
...STYLE_OPTIONS,
|
|
32
|
+
...HELP_OPTIONS,
|
|
33
|
+
out: { type: 'string', short: 'o' },
|
|
34
|
+
frames: { type: 'string' },
|
|
35
|
+
fps: { type: 'string' },
|
|
36
|
+
turns: { type: 'string' },
|
|
37
|
+
force: { type: 'boolean' },
|
|
38
|
+
};
|
|
39
|
+
const kb = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`);
|
|
40
|
+
function version() {
|
|
41
|
+
return JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8')).version;
|
|
42
|
+
}
|
|
43
|
+
export function run(argv) {
|
|
44
|
+
const { values: o, positionals } = parse(argv, OPTIONS);
|
|
45
|
+
if (o.help)
|
|
46
|
+
return void process.stdout.write(HELP);
|
|
47
|
+
if (positionals.length > 1)
|
|
48
|
+
fail(`unexpected argument "${positionals[1]}"`);
|
|
49
|
+
const input = loadInput(o, positionals);
|
|
50
|
+
if (!input)
|
|
51
|
+
return void process.stdout.write(HELP);
|
|
52
|
+
const frames = num(o.frames, '--frames') ?? 120, fps = num(o.fps, '--fps') ?? 30, turns = num(o.turns, '--turns') ?? 1;
|
|
53
|
+
if (!(frames >= 1 && frames <= 10000 && Number.isInteger(frames)))
|
|
54
|
+
fail('--frames is a whole number from 1 to 10000');
|
|
55
|
+
if (!(fps > 0 && fps <= 120))
|
|
56
|
+
fail('--fps is between 0 and 120');
|
|
57
|
+
if (turns === 0)
|
|
58
|
+
fail('--turns cannot be 0');
|
|
59
|
+
const stem = o.example ? o.example.toLowerCase() : basename(positionals[0], extname(positionals[0]));
|
|
60
|
+
const out = resolve(o.out ?? `${stem}.textel.json`);
|
|
61
|
+
if (existsSync(out) && !o.force)
|
|
62
|
+
fail(`${basename(out)} exists; pass --force to overwrite`);
|
|
63
|
+
const flags = styleFrom(o, input.style);
|
|
64
|
+
const style = mergeStyles({ width: 112, height: 56, aspect: 2 }, flags);
|
|
65
|
+
let renderer;
|
|
66
|
+
try {
|
|
67
|
+
renderer = createRenderer(input.mesh, style);
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
fail(e.message);
|
|
71
|
+
}
|
|
72
|
+
const progress = process.stderr.isTTY;
|
|
73
|
+
const t0 = performance.now();
|
|
74
|
+
let shown = -1;
|
|
75
|
+
const { width: _w, height: _h, ...look } = style;
|
|
76
|
+
const rec = recordLoop(renderer, {
|
|
77
|
+
frames, fps, turns,
|
|
78
|
+
// enough to render the same file again
|
|
79
|
+
meta: { title: input.name, generator: `textel ${version()}`, turns, style: look },
|
|
80
|
+
onFrame: (i, total) => {
|
|
81
|
+
const pct = Math.floor((i / total) * 100);
|
|
82
|
+
if (!progress || pct === shown)
|
|
83
|
+
return;
|
|
84
|
+
shown = pct;
|
|
85
|
+
process.stderr.write(`\rrendering ${i}/${total} (${pct}%)`);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
const json = stringifyFrames(rec);
|
|
89
|
+
writeFileSync(out, json);
|
|
90
|
+
if (progress)
|
|
91
|
+
process.stderr.write('\r\x1b[K');
|
|
92
|
+
const secs = ((performance.now() - t0) / 1000).toFixed(1);
|
|
93
|
+
process.stderr.write(`wrote ${basename(out)}: ${frames} frames, ${rec.width}x${rec.height}, ${kb(json.length)} (${kb(gzipSync(json).length)} gzipped), ${secs}s\n`);
|
|
94
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Mesh } from '../mesh.ts';
|
|
2
|
+
import { type ColorDepth } from '../output.ts';
|
|
3
|
+
import { type StyleInput } from '../style.ts';
|
|
4
|
+
export interface LiveOptions {
|
|
5
|
+
mesh: Mesh;
|
|
6
|
+
name: string;
|
|
7
|
+
style: StyleInput;
|
|
8
|
+
fixedSize: [number, number] | null;
|
|
9
|
+
depth: ColorDepth;
|
|
10
|
+
fps: number;
|
|
11
|
+
}
|
|
12
|
+
/** Full-screen live rendering in the terminal until q / Ctrl+C. */
|
|
13
|
+
export declare function live(opts: LiveOptions): void;
|
package/dist/cli/live.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { toANSI } from "../output.js";
|
|
2
|
+
import { createRenderer } from "../render.js";
|
|
3
|
+
import { CHARSETS, mergeStyles, resolveStyle } from "../style.js";
|
|
4
|
+
import { Screen } from "../terminal.js";
|
|
5
|
+
const EDGE_MODES = [false, 'outline', 'depth'];
|
|
6
|
+
/** Full-screen live rendering in the terminal until q / Ctrl+C. */
|
|
7
|
+
export function live(opts) {
|
|
8
|
+
const base = resolveStyle(opts.style);
|
|
9
|
+
let screen;
|
|
10
|
+
const size = () => opts.fixedSize ?? [Math.max(10, screen.columns), Math.max(5, screen.rows - 1)];
|
|
11
|
+
const charsets = Object.keys(CHARSETS);
|
|
12
|
+
const initialCharset = opts.style.chars !== undefined ? 'custom' : opts.style.charset ?? 'classic';
|
|
13
|
+
let charsetName = initialCharset;
|
|
14
|
+
let edgeMode = EDGE_MODES.indexOf(base.edges);
|
|
15
|
+
let yaw = 0, pitchOffset = 0, zoom = base.zoom, light = 0, paused = base.spin === 0;
|
|
16
|
+
const spin = base.spin || 0.66;
|
|
17
|
+
let fps = 0, frames = 0, acc = 0, last = performance.now();
|
|
18
|
+
let timer;
|
|
19
|
+
const quit = (code = 0) => {
|
|
20
|
+
clearInterval(timer);
|
|
21
|
+
screen.close();
|
|
22
|
+
process.exit(code);
|
|
23
|
+
};
|
|
24
|
+
const onKey = (key) => {
|
|
25
|
+
switch (key) {
|
|
26
|
+
case 'q':
|
|
27
|
+
case 'Q':
|
|
28
|
+
case '\x03':
|
|
29
|
+
case '\x1b':
|
|
30
|
+
quit();
|
|
31
|
+
break;
|
|
32
|
+
case ' ':
|
|
33
|
+
paused = !paused;
|
|
34
|
+
break;
|
|
35
|
+
case '\x1b[C':
|
|
36
|
+
yaw += 0.2;
|
|
37
|
+
break;
|
|
38
|
+
case '\x1b[D':
|
|
39
|
+
yaw -= 0.2;
|
|
40
|
+
break;
|
|
41
|
+
case '\x1b[A':
|
|
42
|
+
pitchOffset -= 0.15;
|
|
43
|
+
break;
|
|
44
|
+
case '\x1b[B':
|
|
45
|
+
pitchOffset += 0.15;
|
|
46
|
+
break;
|
|
47
|
+
case '+':
|
|
48
|
+
case '=':
|
|
49
|
+
zoom *= 1.1;
|
|
50
|
+
renderer.setStyle({ zoom });
|
|
51
|
+
break;
|
|
52
|
+
case '-':
|
|
53
|
+
case '_':
|
|
54
|
+
zoom /= 1.1;
|
|
55
|
+
renderer.setStyle({ zoom });
|
|
56
|
+
break;
|
|
57
|
+
case 'c': {
|
|
58
|
+
const i = charsets.indexOf(charsetName);
|
|
59
|
+
charsetName = charsets[(i + 1) % charsets.length];
|
|
60
|
+
renderer.setStyle({ charset: charsetName, chars: undefined });
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case 'e':
|
|
64
|
+
edgeMode = (edgeMode + 1) % EDGE_MODES.length;
|
|
65
|
+
renderer.setStyle({ edges: EDGE_MODES[edgeMode] });
|
|
66
|
+
break;
|
|
67
|
+
case 'l': {
|
|
68
|
+
// swing the light around the model in 45° steps
|
|
69
|
+
light = (light + 1) % 8;
|
|
70
|
+
const a = (light * Math.PI) / 4, [lx, ly, lz] = base.light;
|
|
71
|
+
renderer.setStyle({ light: [lx * Math.cos(a) + lz * Math.sin(a), ly, -lx * Math.sin(a) + lz * Math.cos(a)] });
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case 'r':
|
|
75
|
+
yaw = 0;
|
|
76
|
+
pitchOffset = 0;
|
|
77
|
+
zoom = base.zoom;
|
|
78
|
+
light = 0;
|
|
79
|
+
edgeMode = EDGE_MODES.indexOf(base.edges);
|
|
80
|
+
charsetName = initialCharset;
|
|
81
|
+
renderer.setStyle({ ...opts.style, zoom, light: base.light, edges: base.edges });
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
screen = new Screen({
|
|
86
|
+
onKey,
|
|
87
|
+
onResize: () => {
|
|
88
|
+
if (opts.fixedSize)
|
|
89
|
+
return;
|
|
90
|
+
const [w, h] = size();
|
|
91
|
+
renderer.setStyle({ width: w, height: h });
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
process.on('SIGTERM', () => quit());
|
|
95
|
+
process.on('uncaughtException', (e) => { screen.close(); console.error(e); process.exit(1); });
|
|
96
|
+
const [w0, h0] = size();
|
|
97
|
+
const renderer = createRenderer(opts.mesh, mergeStyles(opts.style, { width: w0, height: h0 }));
|
|
98
|
+
const tick = () => {
|
|
99
|
+
const now = performance.now(), dt = Math.min(now - last, 100) / 1000;
|
|
100
|
+
last = now;
|
|
101
|
+
frames++;
|
|
102
|
+
acc += dt;
|
|
103
|
+
if (acc >= 0.5) {
|
|
104
|
+
fps = Math.round(frames / acc);
|
|
105
|
+
frames = 0;
|
|
106
|
+
acc = 0;
|
|
107
|
+
}
|
|
108
|
+
if (!paused)
|
|
109
|
+
yaw += spin * dt;
|
|
110
|
+
const frame = renderer.render({ yaw, pitch: base.pitch + pitchOffset });
|
|
111
|
+
const edge = EDGE_MODES[edgeMode] || 'off';
|
|
112
|
+
screen.draw(toANSI(frame, opts.depth), ` ${opts.name} · ${frame.width}×${frame.height} · ${charsetName} · edges ${edge} · ${fps}fps${paused ? ' · paused' : ''} ←→↑↓ rotate +/- zoom c chars e edges l light space pause r reset q quit`);
|
|
113
|
+
};
|
|
114
|
+
tick();
|
|
115
|
+
timer = setInterval(tick, 1000 / Math.max(1, Math.min(120, opts.fps)));
|
|
116
|
+
}
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import * as list from "./commands/list.js";
|
|
4
|
+
import * as play from "./commands/play.js";
|
|
5
|
+
import * as preview from "./commands/preview.js";
|
|
6
|
+
import * as render from "./commands/render.js";
|
|
7
|
+
import { fail } from "./shared.js";
|
|
8
|
+
const COMMANDS = { preview, render, play, list };
|
|
9
|
+
const HELP = `textel: 3D models as animated ASCII art
|
|
10
|
+
|
|
11
|
+
usage
|
|
12
|
+
textel <model> [options] spin it in the terminal (same as textel preview)
|
|
13
|
+
textel <command> [options]
|
|
14
|
+
|
|
15
|
+
commands
|
|
16
|
+
${Object.entries(COMMANDS).map(([name, c]) => ` ${name.padEnd(10)} ${c.summary}`).join('\n')}
|
|
17
|
+
help help for a command: textel help preview
|
|
18
|
+
|
|
19
|
+
-v, --version
|
|
20
|
+
-h, --help
|
|
21
|
+
|
|
22
|
+
examples
|
|
23
|
+
textel model.glb
|
|
24
|
+
textel --example gem --color "#301000,#ffb000"
|
|
25
|
+
textel render logo.glb -o logo.textel.json
|
|
26
|
+
textel play logo.textel.json
|
|
27
|
+
textel model.stl --once --size 80x40 > art.txt
|
|
28
|
+
`;
|
|
29
|
+
function version() {
|
|
30
|
+
return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
31
|
+
}
|
|
32
|
+
function main(argv) {
|
|
33
|
+
const [first, ...rest] = argv;
|
|
34
|
+
if (first === undefined || first === '-h' || first === '--help')
|
|
35
|
+
return void process.stdout.write(HELP);
|
|
36
|
+
if (first === '-v' || first === '--version')
|
|
37
|
+
return void process.stdout.write(version() + '\n');
|
|
38
|
+
if (first === 'help') {
|
|
39
|
+
if (!rest[0])
|
|
40
|
+
return void process.stdout.write(HELP);
|
|
41
|
+
const c = COMMANDS[rest[0]] ?? fail(`no command "${rest[0]}" (have: ${Object.keys(COMMANDS).join(', ')})`);
|
|
42
|
+
return void process.stdout.write(c.HELP);
|
|
43
|
+
}
|
|
44
|
+
// kept from 0.1
|
|
45
|
+
if (first === '--list')
|
|
46
|
+
return list.run(rest);
|
|
47
|
+
const command = COMMANDS[first];
|
|
48
|
+
if (command)
|
|
49
|
+
return command.run(rest);
|
|
50
|
+
// anything else is a model path or preview flags
|
|
51
|
+
preview.run(argv);
|
|
52
|
+
}
|
|
53
|
+
main(process.argv.slice(2));
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type ParseArgsConfig } from 'node:util';
|
|
2
|
+
import type { Mesh } from '../mesh.ts';
|
|
3
|
+
import type { ColorDepth } from '../output.ts';
|
|
4
|
+
import { type StyleInput } from '../style.ts';
|
|
5
|
+
export declare function fail(msg: string): never;
|
|
6
|
+
export declare const num: (s: string | undefined, what: string) => number | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* `--edges` may come without a value (it then means outline), and values may be negative numbers,
|
|
9
|
+
* which parseArgs would otherwise read as flags: `--pitch -0.3` becomes `--pitch=-0.3`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function normalizeArgv(argv: string[]): string[];
|
|
12
|
+
type Options = NonNullable<ParseArgsConfig['options']>;
|
|
13
|
+
/** where the model comes from */
|
|
14
|
+
export declare const INPUT_OPTIONS: {
|
|
15
|
+
example: {
|
|
16
|
+
type: "string";
|
|
17
|
+
short: string;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
/** everything that changes the look; all of it can also come from a preset file */
|
|
21
|
+
export declare const STYLE_OPTIONS: {
|
|
22
|
+
chars: {
|
|
23
|
+
type: "string";
|
|
24
|
+
short: string;
|
|
25
|
+
};
|
|
26
|
+
preset: {
|
|
27
|
+
type: "string";
|
|
28
|
+
short: string;
|
|
29
|
+
};
|
|
30
|
+
color: {
|
|
31
|
+
type: "string";
|
|
32
|
+
};
|
|
33
|
+
'no-color': {
|
|
34
|
+
type: "boolean";
|
|
35
|
+
};
|
|
36
|
+
edges: {
|
|
37
|
+
type: "string";
|
|
38
|
+
};
|
|
39
|
+
light: {
|
|
40
|
+
type: "string";
|
|
41
|
+
};
|
|
42
|
+
ambient: {
|
|
43
|
+
type: "string";
|
|
44
|
+
};
|
|
45
|
+
shading: {
|
|
46
|
+
type: "string";
|
|
47
|
+
};
|
|
48
|
+
pitch: {
|
|
49
|
+
type: "string";
|
|
50
|
+
};
|
|
51
|
+
spin: {
|
|
52
|
+
type: "string";
|
|
53
|
+
};
|
|
54
|
+
zoom: {
|
|
55
|
+
type: "string";
|
|
56
|
+
};
|
|
57
|
+
distance: {
|
|
58
|
+
type: "string";
|
|
59
|
+
};
|
|
60
|
+
size: {
|
|
61
|
+
type: "string";
|
|
62
|
+
short: string;
|
|
63
|
+
};
|
|
64
|
+
aspect: {
|
|
65
|
+
type: "string";
|
|
66
|
+
short: string;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
export declare const HELP_OPTIONS: {
|
|
70
|
+
help: {
|
|
71
|
+
type: "boolean";
|
|
72
|
+
short: string;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
export declare const INPUT_HELP = "input\n <model> a .glb, .gltf, .obj or .stl file\n -e, --example <name> a built-in scene instead (textel list)";
|
|
76
|
+
export declare const STYLE_HELP = "look\n -c, --chars <ramp|name> charset name or a custom ramp, darkest first\n -p, --preset <file.json> style file; flags override it\n --color <#hex> one color; --color #301000,#ffb000 for a gradient\n --no-color plain characters\n --edges [depth] outline the silhouette; depth also marks depth steps; off\n --light <x,y,z> direction toward the light (x right, y up, z toward you)\n --ambient <0..1> brightness floor\n --shading <mode> auto | flat | smooth\n\ncamera\n --pitch <rad> resting tilt; positive shows the top\n --spin <rad/s> auto rotation speed (0 = still)\n --zoom <n> 1 fills the grid\n --distance <n> camera distance in model radii (perspective)\n -s, --size <WxH> grid size\n -a, --aspect <n> character height / width (default 2)";
|
|
77
|
+
/** parseArgs with our argv fixes; errors become a clean message */
|
|
78
|
+
export declare function parse<O extends Options>(argv: string[], options: O): {
|
|
79
|
+
values: O extends import("util").ParseArgsOptionsConfig ? { -readonly [LongOption in keyof O]?: { [LongOption_2 in keyof O]: O[LongOption_2]["multiple"] extends infer T ? T extends O[LongOption_2]["multiple"] ? T extends false ? O[LongOption_2]["type"] extends "string" ? string : O[LongOption_2]["type"] extends "boolean" ? boolean : string | boolean : T extends true ? (O[LongOption_2]["type"] extends "string" ? string : O[LongOption_2]["type"] extends "boolean" ? boolean : string | boolean)[] : O[LongOption_2]["type"] extends "string" ? string : O[LongOption_2]["type"] extends "boolean" ? boolean : string | boolean : never : never; }[LongOption] | undefined; } & { [LongOption_1 in keyof O as O[LongOption_1]["default"] extends {} ? LongOption_1 : never]: { [LongOption_3 in keyof O]: O[LongOption_3]["multiple"] extends infer T ? T extends O[LongOption_3]["multiple"] ? T extends false ? O[LongOption_3]["type"] extends "string" ? string : O[LongOption_3]["type"] extends "boolean" ? boolean : string | boolean : T extends true ? (O[LongOption_3]["type"] extends "string" ? string : O[LongOption_3]["type"] extends "boolean" ? boolean : string | boolean)[] : O[LongOption_3]["type"] extends "string" ? string : O[LongOption_3]["type"] extends "boolean" ? boolean : string | boolean : never : never; }[LongOption_1]; } extends infer P ? { [K in keyof P]: P[K]; } : never : {};
|
|
80
|
+
positionals: string[];
|
|
81
|
+
};
|
|
82
|
+
type StyleValues = {
|
|
83
|
+
[K in keyof typeof STYLE_OPTIONS]?: (typeof STYLE_OPTIONS)[K]['type'] extends 'boolean' ? boolean : string;
|
|
84
|
+
};
|
|
85
|
+
type InputValues = {
|
|
86
|
+
example?: string;
|
|
87
|
+
};
|
|
88
|
+
export interface Input {
|
|
89
|
+
mesh: Mesh;
|
|
90
|
+
/** shown in status lines and metadata */
|
|
91
|
+
name: string;
|
|
92
|
+
/** the example's own look, if it is one */
|
|
93
|
+
style: StyleInput;
|
|
94
|
+
}
|
|
95
|
+
/** the model from `--example` or the first positional; null when neither was given */
|
|
96
|
+
export declare function loadInput(values: InputValues, positionals: string[]): Input | null;
|
|
97
|
+
/** `--size WxH`, or null */
|
|
98
|
+
export declare function parseSize(s: string | undefined): [number, number] | null;
|
|
99
|
+
/** the final style: the example's look < preset file < flags */
|
|
100
|
+
export declare function styleFrom(o: StyleValues, base: StyleInput): StyleInput;
|
|
101
|
+
/** what stdout can show, honoring NO_COLOR / FORCE_COLOR and --no-color */
|
|
102
|
+
export declare function colorDepth(noColor: boolean | undefined): ColorDepth;
|
|
103
|
+
export {};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Pieces every command shares: argument parsing, model input, style flags, color detection.
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { buildExample, EXAMPLES, findExample } from "../examples.js";
|
|
6
|
+
import { parseModel } from "../loaders/index.js";
|
|
7
|
+
import { detectColorDepth } from "../terminal.js";
|
|
8
|
+
import { CHARSETS, mergeStyles } from "../style.js";
|
|
9
|
+
export function fail(msg) {
|
|
10
|
+
process.stderr.write(`textel: ${msg}\n`);
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
export const num = (s, what) => {
|
|
14
|
+
if (s === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
const v = Number(s);
|
|
17
|
+
if (!Number.isFinite(v))
|
|
18
|
+
fail(`${what} is not a number: ${s}`);
|
|
19
|
+
return v;
|
|
20
|
+
};
|
|
21
|
+
const nums = (s, n, what) => {
|
|
22
|
+
const v = s.split(',').map(Number);
|
|
23
|
+
if (v.length !== n || v.some((x) => !Number.isFinite(x)))
|
|
24
|
+
fail(`${what} needs ${n} comma separated numbers`);
|
|
25
|
+
return v;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* `--edges` may come without a value (it then means outline), and values may be negative numbers,
|
|
29
|
+
* which parseArgs would otherwise read as flags: `--pitch -0.3` becomes `--pitch=-0.3`.
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeArgv(argv) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const a = argv[i], next = argv[i + 1];
|
|
35
|
+
if (a === '--edges' && !['outline', 'depth', 'off'].includes(next))
|
|
36
|
+
out.push('--edges=outline');
|
|
37
|
+
else if (/^--?[a-z]/i.test(a) && !a.includes('=') && next !== undefined && /^-[\d.]/.test(next)) {
|
|
38
|
+
out.push(`${a}=${next}`);
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
else
|
|
42
|
+
out.push(a);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/** where the model comes from */
|
|
47
|
+
export const INPUT_OPTIONS = {
|
|
48
|
+
example: { type: 'string', short: 'e' },
|
|
49
|
+
};
|
|
50
|
+
/** everything that changes the look; all of it can also come from a preset file */
|
|
51
|
+
export const STYLE_OPTIONS = {
|
|
52
|
+
chars: { type: 'string', short: 'c' },
|
|
53
|
+
preset: { type: 'string', short: 'p' },
|
|
54
|
+
color: { type: 'string' },
|
|
55
|
+
'no-color': { type: 'boolean' },
|
|
56
|
+
edges: { type: 'string' },
|
|
57
|
+
light: { type: 'string' },
|
|
58
|
+
ambient: { type: 'string' },
|
|
59
|
+
shading: { type: 'string' },
|
|
60
|
+
pitch: { type: 'string' },
|
|
61
|
+
spin: { type: 'string' },
|
|
62
|
+
zoom: { type: 'string' },
|
|
63
|
+
distance: { type: 'string' },
|
|
64
|
+
size: { type: 'string', short: 's' },
|
|
65
|
+
aspect: { type: 'string', short: 'a' },
|
|
66
|
+
};
|
|
67
|
+
export const HELP_OPTIONS = {
|
|
68
|
+
help: { type: 'boolean', short: 'h' },
|
|
69
|
+
};
|
|
70
|
+
export const INPUT_HELP = `input
|
|
71
|
+
<model> a .glb, .gltf, .obj or .stl file
|
|
72
|
+
-e, --example <name> a built-in scene instead (textel list)`;
|
|
73
|
+
export const STYLE_HELP = `look
|
|
74
|
+
-c, --chars <ramp|name> charset name or a custom ramp, darkest first
|
|
75
|
+
-p, --preset <file.json> style file; flags override it
|
|
76
|
+
--color <#hex> one color; --color #301000,#ffb000 for a gradient
|
|
77
|
+
--no-color plain characters
|
|
78
|
+
--edges [depth] outline the silhouette; depth also marks depth steps; off
|
|
79
|
+
--light <x,y,z> direction toward the light (x right, y up, z toward you)
|
|
80
|
+
--ambient <0..1> brightness floor
|
|
81
|
+
--shading <mode> auto | flat | smooth
|
|
82
|
+
|
|
83
|
+
camera
|
|
84
|
+
--pitch <rad> resting tilt; positive shows the top
|
|
85
|
+
--spin <rad/s> auto rotation speed (0 = still)
|
|
86
|
+
--zoom <n> 1 fills the grid
|
|
87
|
+
--distance <n> camera distance in model radii (perspective)
|
|
88
|
+
-s, --size <WxH> grid size
|
|
89
|
+
-a, --aspect <n> character height / width (default 2)`;
|
|
90
|
+
/** parseArgs with our argv fixes; errors become a clean message */
|
|
91
|
+
export function parse(argv, options) {
|
|
92
|
+
try {
|
|
93
|
+
return parseArgs({ args: normalizeArgv(argv), allowPositionals: true, options, strict: true });
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
fail(e.message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** the model from `--example` or the first positional; null when neither was given */
|
|
100
|
+
export function loadInput(values, positionals) {
|
|
101
|
+
if (values.example) {
|
|
102
|
+
const ex = findExample(values.example) ?? fail(`no example "${values.example}" (have: ${EXAMPLES.map((e) => e.name).join(', ')})`);
|
|
103
|
+
return { mesh: buildExample(ex), name: ex.title, style: ex.style };
|
|
104
|
+
}
|
|
105
|
+
const arg = positionals[0];
|
|
106
|
+
if (!arg)
|
|
107
|
+
return null;
|
|
108
|
+
const file = resolve(arg);
|
|
109
|
+
let bytes;
|
|
110
|
+
try {
|
|
111
|
+
bytes = readFileSync(file);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
fail(`cannot read ${arg}`);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return { mesh: parseModel(bytes, file, { resolve: (uri) => readFileSync(resolve(dirname(file), uri)) }), name: basename(file), style: {} };
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
fail(`${basename(file)}: ${e.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** `--size WxH`, or null */
|
|
124
|
+
export function parseSize(s) {
|
|
125
|
+
if (!s)
|
|
126
|
+
return null;
|
|
127
|
+
const m = /^(\d+)x(\d+)$/i.exec(s) ?? fail('--size looks like 112x56');
|
|
128
|
+
return [+m[1], +m[2]];
|
|
129
|
+
}
|
|
130
|
+
/** the final style: the example's look < preset file < flags */
|
|
131
|
+
export function styleFrom(o, base) {
|
|
132
|
+
let preset = {};
|
|
133
|
+
if (o.preset) {
|
|
134
|
+
try {
|
|
135
|
+
preset = JSON.parse(readFileSync(resolve(o.preset), 'utf8'));
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
fail(`preset: ${e.message}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const flags = {};
|
|
142
|
+
if (o.chars !== undefined) {
|
|
143
|
+
if (CHARSETS[o.chars])
|
|
144
|
+
flags.charset = o.chars;
|
|
145
|
+
else
|
|
146
|
+
flags.chars = o.chars;
|
|
147
|
+
}
|
|
148
|
+
if (o.color)
|
|
149
|
+
flags.color = o.color.includes(',') ? o.color.split(',') : o.color;
|
|
150
|
+
if (o.edges !== undefined) {
|
|
151
|
+
if (o.edges !== 'outline' && o.edges !== 'depth' && o.edges !== 'off')
|
|
152
|
+
fail('--edges is outline, depth or off');
|
|
153
|
+
flags.edges = o.edges === 'off' ? false : o.edges;
|
|
154
|
+
}
|
|
155
|
+
if (o.light)
|
|
156
|
+
flags.light = nums(o.light, 3, '--light');
|
|
157
|
+
if (o.shading) {
|
|
158
|
+
if (o.shading !== 'auto' && o.shading !== 'flat' && o.shading !== 'smooth')
|
|
159
|
+
fail('--shading is auto, flat or smooth');
|
|
160
|
+
flags.shading = o.shading;
|
|
161
|
+
}
|
|
162
|
+
flags.ambient = num(o.ambient, '--ambient');
|
|
163
|
+
flags.pitch = num(o.pitch, '--pitch');
|
|
164
|
+
flags.spin = num(o.spin, '--spin');
|
|
165
|
+
flags.zoom = num(o.zoom, '--zoom');
|
|
166
|
+
flags.distance = num(o.distance, '--distance');
|
|
167
|
+
flags.aspect = num(o.aspect, '--aspect');
|
|
168
|
+
const size = parseSize(o.size);
|
|
169
|
+
if (size)
|
|
170
|
+
[flags.width, flags.height] = size;
|
|
171
|
+
// a custom ramp from -c replaces the example's charset as well
|
|
172
|
+
const b = flags.chars !== undefined ? { ...base, charset: undefined } : base;
|
|
173
|
+
return mergeStyles(b, preset, flags);
|
|
174
|
+
}
|
|
175
|
+
/** what stdout can show, honoring NO_COLOR / FORCE_COLOR and --no-color */
|
|
176
|
+
export function colorDepth(noColor) {
|
|
177
|
+
return noColor ? 'none' : detectColorDepth(process.stdout);
|
|
178
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Mesh } from './mesh.ts';
|
|
2
|
+
import type { StyleInput } from './style.ts';
|
|
3
|
+
export interface Example {
|
|
4
|
+
name: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
/** look that suits the scene; user options are merged on top */
|
|
8
|
+
style: StyleInput;
|
|
9
|
+
build: () => Generator<void, Mesh>;
|
|
10
|
+
}
|
|
11
|
+
export declare const EXAMPLES: Example[];
|
|
12
|
+
export declare function findExample(name: string): Example | undefined;
|
|
13
|
+
export declare function buildExample(e: Example): Mesh;
|