@feelgxxd/textel 0.0.0-stage → 0.3.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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -2
  3. package/dist/ansi.d.ts +13 -0
  4. package/dist/ansi.js +46 -0
  5. package/dist/browser/player-core.d.ts +23 -0
  6. package/dist/browser/player-core.js +68 -0
  7. package/dist/browser/player.d.ts +25 -0
  8. package/dist/browser/player.js +198 -0
  9. package/dist/browser/viewer-core.d.ts +42 -0
  10. package/dist/browser/viewer-core.js +67 -0
  11. package/dist/browser/viewer.d.ts +29 -0
  12. package/dist/browser/viewer.js +338 -0
  13. package/dist/cli/commands/list.d.ts +3 -0
  14. package/dist/cli/commands/list.js +21 -0
  15. package/dist/cli/commands/play.d.ts +3 -0
  16. package/dist/cli/commands/play.js +55 -0
  17. package/dist/cli/commands/preview.d.ts +3 -0
  18. package/dist/cli/commands/preview.js +57 -0
  19. package/dist/cli/commands/render.d.ts +3 -0
  20. package/dist/cli/commands/render.js +94 -0
  21. package/dist/cli/live.d.ts +13 -0
  22. package/dist/cli/live.js +116 -0
  23. package/dist/cli/main.d.ts +2 -0
  24. package/dist/cli/main.js +53 -0
  25. package/dist/cli/shared.d.ts +103 -0
  26. package/dist/cli/shared.js +178 -0
  27. package/dist/examples.d.ts +13 -0
  28. package/dist/examples.js +164 -0
  29. package/dist/format.d.ts +57 -0
  30. package/dist/format.js +262 -0
  31. package/dist/frames.d.ts +34 -0
  32. package/dist/frames.js +88 -0
  33. package/dist/index.d.ts +6 -0
  34. package/dist/index.js +6 -0
  35. package/dist/loaders/gltf.d.ts +7 -0
  36. package/dist/loaders/gltf.js +187 -0
  37. package/dist/loaders/index.d.ts +18 -0
  38. package/dist/loaders/index.js +58 -0
  39. package/dist/loaders/obj.d.ts +8 -0
  40. package/dist/loaders/obj.js +69 -0
  41. package/dist/loaders/stl.d.ts +3 -0
  42. package/dist/loaders/stl.js +29 -0
  43. package/dist/mesh.d.ts +35 -0
  44. package/dist/mesh.js +106 -0
  45. package/dist/output.d.ts +18 -0
  46. package/dist/output.js +88 -0
  47. package/dist/render.d.ts +36 -0
  48. package/dist/render.js +309 -0
  49. package/dist/sdf.d.ts +36 -0
  50. package/dist/sdf.js +151 -0
  51. package/dist/style.d.ts +138 -0
  52. package/dist/style.js +132 -0
  53. package/dist/terminal.d.ts +89 -0
  54. package/dist/terminal.js +197 -0
  55. package/dist/textel-player.js +7 -0
  56. package/dist/textel-viewer.js +10 -0
  57. package/package.json +90 -4
@@ -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;
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -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;