@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,67 @@
1
+ // The parts of <textel-viewer> that do not touch the DOM, so they can be tested anywhere.
2
+ /**
3
+ * The grid for a box: `cols` is fixed, rows follow from the box shape in this font.
4
+ * A box without a height gets a square picture.
5
+ */
6
+ export function gridFor(cols, cellW, cellH, boxW, boxH) {
7
+ const c = Math.max(8, Math.round(cols));
8
+ const cw = cellW > 0 ? cellW : 0.6, ch = cellH > 0 ? cellH : 1;
9
+ const wantRatio = boxW > 0 && boxH > 0 ? boxW / boxH : 1;
10
+ const rows = Math.max(4, Math.min(400, Math.round((c * cw) / (ch * wantRatio))));
11
+ const w = c * cw, h = rows * ch;
12
+ return { cols: c, rows, aspect: ch / cw, scale: boxW > 0 ? boxW / w : 1, ratio: w / h };
13
+ }
14
+ const ease = (rate, dt) => 1 - Math.exp(-rate * dt);
15
+ /**
16
+ * Advances the turntable by `dt` seconds. After a drag the flick keeps going and eases back to the
17
+ * resting spin, and the tilt drifts back to the resting pitch.
18
+ */
19
+ export function step(m, t, dt) {
20
+ const d = Math.min(Math.max(dt, 0), 0.1);
21
+ const vyaw = m.vyaw + (t.spin - m.vyaw) * ease(2, d);
22
+ const vpitch = m.vpitch * (1 - ease(4, d));
23
+ let pitch = m.pitch + vpitch * d;
24
+ pitch += (t.pitch - pitch) * ease(1.5, d);
25
+ pitch = Math.max(-t.maxPitch, Math.min(t.maxPitch, pitch));
26
+ return { yaw: m.yaw + vyaw * d, pitch, vyaw, vpitch };
27
+ }
28
+ /**
29
+ * A pointer moved by (dx, dy) pixels over `dt` seconds on a box `width` wide: a drag across the
30
+ * whole width turns the model once. The speed is kept so a release turns into a flick.
31
+ */
32
+ export function drag(m, dx, dy, dt, width, maxPitch) {
33
+ const k = width > 0 ? (Math.PI * 2) / width : 0.01;
34
+ const dyaw = dx * k, dpitch = dy * k;
35
+ const pitch = Math.max(-maxPitch, Math.min(maxPitch, m.pitch + dpitch));
36
+ const inv = dt > 0.001 ? 1 / dt : 0;
37
+ return { yaw: m.yaw + dyaw, pitch, vyaw: dyaw * inv, vpitch: dpitch * inv };
38
+ }
39
+ /** attribute values to style fields; unknown or empty values are left out */
40
+ export function styleFromAttributes(get) {
41
+ const out = {};
42
+ const n = (name) => {
43
+ const v = get(name);
44
+ if (v === null || v.trim() === '')
45
+ return undefined;
46
+ const x = Number(v);
47
+ return Number.isFinite(x) ? x : undefined;
48
+ };
49
+ const charset = get('charset');
50
+ if (charset)
51
+ out.charset = charset;
52
+ const chars = get('chars');
53
+ if (chars)
54
+ out.chars = chars;
55
+ const color = get('color');
56
+ if (color)
57
+ out.color = color.includes(',') ? color.split(',').map((s) => s.trim()) : color.trim();
58
+ const edges = get('edges');
59
+ if (edges !== null)
60
+ out.edges = edges === 'depth' ? 'depth' : edges === 'off' || edges === 'false' ? false : 'outline';
61
+ for (const k of ['spin', 'pitch', 'zoom', 'distance', 'ambient']) {
62
+ const v = n(k);
63
+ if (v !== undefined)
64
+ out[k] = v;
65
+ }
66
+ return out;
67
+ }
@@ -0,0 +1,29 @@
1
+ import type { Mesh } from '../mesh.ts';
2
+ import { type StyleInput } from '../style.ts';
3
+ declare const Base: typeof HTMLElement;
4
+ export declare class TextelViewer extends Base {
5
+ #private;
6
+ static observedAttributes: string[];
7
+ constructor();
8
+ connectedCallback(): void;
9
+ disconnectedCallback(): void;
10
+ attributeChangedCallback(name: string, old: string | null, value: string | null): void;
11
+ /** extra style from JavaScript, on top of the preset and the attributes */
12
+ get look(): StyleInput;
13
+ set look(s: StyleInput);
14
+ /** the model being shown; set a Mesh to show one built in code */
15
+ get mesh(): Mesh | null;
16
+ set mesh(m: Mesh | null);
17
+ get paused(): boolean;
18
+ play(): void;
19
+ /** stops at once: the spin and any flick still coasting are dropped */
20
+ pause(): void;
21
+ /** loads a model from a URL, a File, or bytes */
22
+ load(source: string | Blob | ArrayBuffer | Uint8Array, name?: string): Promise<void>;
23
+ }
24
+ declare global {
25
+ interface HTMLElementTagNameMap {
26
+ 'textel-viewer': TextelViewer;
27
+ }
28
+ }
29
+ export {};
@@ -0,0 +1,338 @@
1
+ // <textel-viewer>: renders a 3D model live in the page; drag to turn it.
2
+ //
3
+ // <script type="module" src="https://cdn.jsdelivr.net/npm/@feelgxxd/textel@0.3/dist/textel-viewer.js"></script>
4
+ // <textel-viewer src="model.glb" charset="hard" color="#301000,#ffb000"></textel-viewer>
5
+ import { parseModelAsync } from "../loaders/index.js";
6
+ import { toHTML, toText } from "../output.js";
7
+ import { createRenderer } from "../render.js";
8
+ import { mergeStyles } from "../style.js";
9
+ import { drag, gridFor, step, styleFromAttributes } from "./viewer-core.js";
10
+ const CSS = `:host{display:block;position:relative;overflow:hidden;aspect-ratio:var(--textel-ratio,1);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;outline:none}
11
+ :host(:not([nodrag])){cursor:grab;touch-action:pan-y}
12
+ :host(:not([nodrag]):active){cursor:grabbing}
13
+ :host(:focus-visible){box-shadow:0 0 0 2px currentColor}
14
+ pre{position:absolute;left:0;top:0;margin:0;transform-origin:0 0;font:inherit;font-size:16px;line-height:1;letter-spacing:0;white-space:pre;color:inherit;text-shadow:var(--textel-glow,none);-webkit-text-size-adjust:none;text-size-adjust:none;user-select:none;pointer-events:none}
15
+ .probe{visibility:hidden}`;
16
+ const MAX_PITCH = 1.2;
17
+ const STYLE_ATTRS = ['charset', 'chars', 'color', 'edges', 'spin', 'pitch', 'zoom', 'distance', 'ambient'];
18
+ const Base = (typeof HTMLElement === 'undefined' ? class {
19
+ } : HTMLElement);
20
+ /** fetches a model; a .gltf brings its external buffers along */
21
+ async function fetchModel(url, signal) {
22
+ const get = async (u) => {
23
+ const res = await fetch(u, { signal });
24
+ if (!res.ok)
25
+ throw new Error(`${res.status} ${res.statusText} for ${u}`);
26
+ return new Uint8Array(await res.arrayBuffer());
27
+ };
28
+ const bytes = await get(url);
29
+ const buffers = new Map();
30
+ if (/\.gltf(\?|#|$)/i.test(url)) {
31
+ const json = JSON.parse(new TextDecoder().decode(bytes));
32
+ for (const b of json.buffers ?? []) {
33
+ if (b.uri && !b.uri.startsWith('data:'))
34
+ buffers.set(decodeURIComponent(b.uri), await get(new URL(b.uri, new URL(url, location.href)).href));
35
+ }
36
+ }
37
+ return parseModelAsync(bytes, url.split(/[?#]/)[0], {
38
+ resolve: (uri) => {
39
+ const b = buffers.get(uri);
40
+ if (!b)
41
+ throw new Error(`missing buffer ${uri}`);
42
+ return b;
43
+ },
44
+ });
45
+ }
46
+ export class TextelViewer extends Base {
47
+ static observedAttributes = ['src', 'preset', 'cols', 'paused', 'label', ...STYLE_ATTRS];
48
+ #pre;
49
+ #probe;
50
+ #mesh = null;
51
+ #renderer = null;
52
+ #preset = {};
53
+ #look = {};
54
+ #grid = null;
55
+ #motion = { yaw: 0, pitch: 0, vyaw: 0, vpitch: 0 };
56
+ #zoom = 1;
57
+ #playing = true;
58
+ #visible = true;
59
+ #raf = 0;
60
+ #last = -1;
61
+ #dirty = true;
62
+ #pointer = null;
63
+ #load = null;
64
+ #io;
65
+ #ro;
66
+ #reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
67
+ constructor() {
68
+ super();
69
+ const root = this.attachShadow({ mode: 'open' });
70
+ const style = document.createElement('style');
71
+ style.textContent = CSS;
72
+ this.#pre = document.createElement('pre');
73
+ this.#pre.setAttribute('part', 'screen');
74
+ this.#probe = document.createElement('pre');
75
+ this.#probe.className = 'probe';
76
+ // 40 glyphs on each of 4 lines: one glyph's width and one line's height, averaged
77
+ this.#probe.textContent = Array(4).fill('M'.repeat(40)).join('\n');
78
+ root.append(style, this.#probe, this.#pre);
79
+ this.addEventListener('pointerdown', this.#onDown);
80
+ this.addEventListener('pointermove', this.#onMove);
81
+ this.addEventListener('pointerup', this.#onUp);
82
+ this.addEventListener('pointercancel', this.#onUp);
83
+ this.addEventListener('keydown', this.#onKey);
84
+ this.addEventListener('wheel', this.#onWheel, { passive: false });
85
+ }
86
+ connectedCallback() {
87
+ this.setAttribute('role', 'img');
88
+ if (!this.hasAttribute('tabindex'))
89
+ this.tabIndex = 0;
90
+ this.#updateLabel();
91
+ this.#playing = !this.hasAttribute('paused');
92
+ if (typeof IntersectionObserver === 'function') {
93
+ this.#io = new IntersectionObserver(([e]) => { this.#visible = e.isIntersecting; this.#kick(); });
94
+ this.#io.observe(this);
95
+ }
96
+ if (typeof ResizeObserver === 'function') {
97
+ this.#ro = new ResizeObserver(() => this.#layout());
98
+ this.#ro.observe(this);
99
+ }
100
+ this.ownerDocument.addEventListener('visibilitychange', this.#kick);
101
+ this.ownerDocument.fonts?.ready.then(() => this.#layout());
102
+ this.#layout();
103
+ this.#kick();
104
+ }
105
+ disconnectedCallback() {
106
+ this.#io?.disconnect();
107
+ this.#ro?.disconnect();
108
+ this.ownerDocument.removeEventListener('visibilitychange', this.#kick);
109
+ cancelAnimationFrame(this.#raf);
110
+ this.#raf = 0;
111
+ }
112
+ attributeChangedCallback(name, old, value) {
113
+ if (old === value)
114
+ return;
115
+ if (name === 'src') {
116
+ if (value)
117
+ this.load(value);
118
+ }
119
+ else if (name === 'preset') {
120
+ if (value)
121
+ this.#fetchPreset(value);
122
+ }
123
+ else if (name === 'paused') {
124
+ value === null ? this.play() : this.pause();
125
+ }
126
+ else if (name === 'label')
127
+ this.#updateLabel();
128
+ else if (name === 'cols')
129
+ this.#layout();
130
+ else
131
+ this.#restyle();
132
+ }
133
+ /** extra style from JavaScript, on top of the preset and the attributes */
134
+ get look() { return this.#look; }
135
+ set look(s) { this.#look = s ?? {}; this.#restyle(); }
136
+ /** the model being shown; set a Mesh to show one built in code */
137
+ get mesh() { return this.#mesh; }
138
+ set mesh(m) {
139
+ this.#mesh = m;
140
+ this.#renderer = null;
141
+ this.#motion = { yaw: 0, pitch: this.#style().pitch ?? 0.3, vyaw: 0, vpitch: 0 };
142
+ this.#layout();
143
+ this.#kick();
144
+ }
145
+ get paused() { return !this.#playing; }
146
+ play() { this.#playing = true; this.#kick(); }
147
+ /** stops at once: the spin and any flick still coasting are dropped */
148
+ pause() {
149
+ this.#playing = false;
150
+ this.#motion = { ...this.#motion, vyaw: 0, vpitch: 0 };
151
+ this.#kick();
152
+ }
153
+ /** loads a model from a URL, a File, or bytes */
154
+ async load(source, name = '') {
155
+ this.#load?.abort();
156
+ const ac = (this.#load = new AbortController());
157
+ try {
158
+ let mesh;
159
+ if (typeof source === 'string')
160
+ mesh = await fetchModel(source, ac.signal);
161
+ else {
162
+ const bytes = source instanceof Uint8Array ? source : new Uint8Array(source instanceof Blob ? await source.arrayBuffer() : source);
163
+ mesh = await parseModelAsync(bytes, name || (source instanceof File ? source.name : ''));
164
+ }
165
+ if (ac.signal.aborted)
166
+ return;
167
+ this.mesh = mesh;
168
+ this.dispatchEvent(new CustomEvent('load', { detail: { triangles: mesh.indices.length / 3, materials: mesh.materials } }));
169
+ }
170
+ catch (error) {
171
+ if (ac.signal.aborted)
172
+ return;
173
+ this.dispatchEvent(new CustomEvent('error', { detail: { error } }));
174
+ }
175
+ }
176
+ async #fetchPreset(url) {
177
+ try {
178
+ const res = await fetch(url);
179
+ if (!res.ok)
180
+ throw new Error(`${res.status} ${res.statusText} for ${url}`);
181
+ this.#preset = await res.json();
182
+ this.#restyle();
183
+ }
184
+ catch (error) {
185
+ this.dispatchEvent(new CustomEvent('error', { detail: { error } }));
186
+ }
187
+ }
188
+ /** preset < attributes < .look, then the parts the viewer owns */
189
+ #style() {
190
+ return mergeStyles(this.#preset, styleFromAttributes((n) => this.getAttribute(n)), this.#look);
191
+ }
192
+ #restyle() {
193
+ if (!this.#renderer || !this.#grid)
194
+ return this.#layout();
195
+ const s = this.#style();
196
+ this.#renderer.setStyle({ ...s, ...this.#gridStyle(s) });
197
+ // show the change now, even while paused
198
+ this.#draw();
199
+ this.#kick();
200
+ }
201
+ #gridStyle(s) {
202
+ const g = this.#grid;
203
+ return { width: g.cols, height: g.rows, aspect: g.aspect, zoom: (s.zoom ?? 1) * this.#zoom, fitPitch: this.hasAttribute('nodrag') ? 0 : MAX_PITCH };
204
+ }
205
+ #layout() {
206
+ if (!this.isConnected)
207
+ return;
208
+ const p = this.#probe, cellW = p.offsetWidth / 40, cellH = p.offsetHeight / 4;
209
+ const cols = Number(this.getAttribute('cols')) || 96;
210
+ // A page-set height shapes the grid. Without one the box follows --textel-ratio (square at
211
+ // first), so its height already matches the grid and the layout settles on the same rows.
212
+ this.#grid = gridFor(cols, cellW, cellH, this.clientWidth, this.clientHeight);
213
+ this.style.setProperty('--textel-ratio', String(this.#grid.ratio));
214
+ this.#pre.style.transform = `scale(${this.#grid.scale})`;
215
+ if (!this.#mesh)
216
+ return;
217
+ const s = this.#style();
218
+ try {
219
+ if (!this.#renderer)
220
+ this.#renderer = createRenderer(this.#mesh, { ...s, ...this.#gridStyle(s) });
221
+ else
222
+ this.#renderer.setStyle({ ...s, ...this.#gridStyle(s) });
223
+ }
224
+ catch (error) {
225
+ this.dispatchEvent(new CustomEvent('error', { detail: { error } }));
226
+ return;
227
+ }
228
+ this.#dirty = true;
229
+ this.#draw();
230
+ }
231
+ #draw() {
232
+ if (!this.#renderer)
233
+ return;
234
+ const frame = this.#renderer.render({ yaw: this.#motion.yaw, pitch: this.#motion.pitch });
235
+ // without any color or dimming the frame is plain text, which is the cheapest thing to draw
236
+ const plain = frame.style.color.mode === 'none' && frame.materials.every((m) => !m.color && m.opacity === 1);
237
+ if (plain)
238
+ this.#pre.textContent = toText(frame);
239
+ else
240
+ this.#pre.innerHTML = toHTML(frame);
241
+ this.#dirty = false;
242
+ }
243
+ #updateLabel() {
244
+ if (this.isConnected)
245
+ this.setAttribute('aria-label', this.getAttribute('label') || '3D model drawn in ASCII');
246
+ }
247
+ #spin() {
248
+ return this.#playing && !this.#reduced ? this.#style().spin ?? 0.66 : 0;
249
+ }
250
+ /** animates while it spins, coasts after a flick, or is being dragged; otherwise it stays still */
251
+ #kick = () => {
252
+ const m = this.#motion;
253
+ const moving = this.#spin() !== 0 || Math.abs(m.vyaw) > 1e-3 || Math.abs(m.vpitch) > 1e-3 || !!this.#pointer;
254
+ const run = !!this.#renderer && this.#visible && !this.ownerDocument.hidden && this.isConnected && (moving || this.#dirty);
255
+ if (run && !this.#raf) {
256
+ this.#last = -1;
257
+ this.#raf = requestAnimationFrame(this.#tick);
258
+ }
259
+ else if (!run && this.#raf) {
260
+ cancelAnimationFrame(this.#raf);
261
+ this.#raf = 0;
262
+ }
263
+ };
264
+ #tick = (now) => {
265
+ this.#raf = 0;
266
+ const dt = this.#last < 0 ? 0 : (now - this.#last) / 1000;
267
+ this.#last = now;
268
+ if (!this.#pointer) {
269
+ this.#motion = step(this.#motion, { spin: this.#spin(), pitch: this.#style().pitch ?? 0.3, maxPitch: MAX_PITCH }, dt);
270
+ }
271
+ this.#draw();
272
+ this.#raf = requestAnimationFrame(this.#tick);
273
+ // stop once everything has settled
274
+ const m = this.#motion;
275
+ if (this.#spin() === 0 && !this.#pointer && Math.abs(m.vyaw) < 1e-3 && Math.abs(m.vpitch) < 1e-3) {
276
+ cancelAnimationFrame(this.#raf);
277
+ this.#raf = 0;
278
+ }
279
+ };
280
+ #onDown = (e) => {
281
+ if (this.hasAttribute('nodrag') || !this.#renderer || e.button !== 0)
282
+ return;
283
+ this.#pointer = { x: e.clientX, y: e.clientY, t: performance.now() };
284
+ this.setPointerCapture(e.pointerId);
285
+ this.#kick();
286
+ };
287
+ #onMove = (e) => {
288
+ const p = this.#pointer;
289
+ if (!p)
290
+ return;
291
+ const now = performance.now();
292
+ this.#motion = drag(this.#motion, e.clientX - p.x, e.clientY - p.y, (now - p.t) / 1000, this.clientWidth, MAX_PITCH);
293
+ this.#pointer = { x: e.clientX, y: e.clientY, t: now };
294
+ this.#kick();
295
+ };
296
+ #onUp = () => {
297
+ if (!this.#pointer)
298
+ return;
299
+ // a pause before letting go means no flick
300
+ if (performance.now() - this.#pointer.t > 80)
301
+ this.#motion = { ...this.#motion, vyaw: 0, vpitch: 0 };
302
+ this.#pointer = null;
303
+ this.#kick();
304
+ };
305
+ #onKey = (e) => {
306
+ const m = this.#motion, step = 0.15;
307
+ const moves = {
308
+ ArrowLeft: () => (m.yaw -= step),
309
+ ArrowRight: () => (m.yaw += step),
310
+ ArrowUp: () => (m.pitch = Math.max(-MAX_PITCH, m.pitch - step)),
311
+ ArrowDown: () => (m.pitch = Math.min(MAX_PITCH, m.pitch + step)),
312
+ ' ': () => (this.#playing ? this.pause() : this.play()),
313
+ };
314
+ if (this.hasAttribute('zoomable')) {
315
+ moves['+'] = moves['='] = () => this.#setZoom(this.#zoom * 1.1);
316
+ moves['-'] = () => this.#setZoom(this.#zoom / 1.1);
317
+ }
318
+ const f = moves[e.key];
319
+ if (!f)
320
+ return;
321
+ e.preventDefault();
322
+ f();
323
+ this.#dirty = true;
324
+ this.#kick();
325
+ };
326
+ #onWheel = (e) => {
327
+ if (!this.hasAttribute('zoomable') || !this.#renderer)
328
+ return;
329
+ e.preventDefault();
330
+ this.#setZoom(this.#zoom * Math.exp(-e.deltaY * 0.001));
331
+ };
332
+ #setZoom(z) {
333
+ this.#zoom = Math.max(0.3, Math.min(4, z));
334
+ this.#restyle();
335
+ }
336
+ }
337
+ if (typeof customElements !== 'undefined' && !customElements.get('textel-viewer'))
338
+ customElements.define('textel-viewer', TextelViewer);
@@ -0,0 +1,3 @@
1
+ export declare const summary = "built-in examples and charsets";
2
+ export declare const HELP = "textel list: built-in examples and charsets\n\nusage\n textel list\n";
3
+ export declare function run(argv: string[]): undefined;
@@ -0,0 +1,21 @@
1
+ import { EXAMPLES } from "../../examples.js";
2
+ import { CHARSETS } from "../../style.js";
3
+ import { HELP_OPTIONS, parse } from "../shared.js";
4
+ export const summary = 'built-in examples and charsets';
5
+ export const HELP = `textel list: built-in examples and charsets
6
+
7
+ usage
8
+ textel list
9
+ `;
10
+ export function run(argv) {
11
+ const { values: o } = parse(argv, HELP_OPTIONS);
12
+ if (o.help)
13
+ return void process.stdout.write(HELP);
14
+ const out = ['examples'];
15
+ for (const e of EXAMPLES)
16
+ out.push(` ${e.name.padEnd(10)} ${e.description}`);
17
+ out.push('', 'charsets');
18
+ for (const [k, v] of Object.entries(CHARSETS))
19
+ out.push(` ${k.padEnd(10)} ${JSON.stringify(v.length > 40 ? v.slice(0, 40) + '…' : v)}`);
20
+ process.stdout.write(out.join('\n') + '\n');
21
+ }
@@ -0,0 +1,3 @@
1
+ export declare const summary = "play a .textel.json file in the terminal";
2
+ export declare const HELP = "textel play: play a .textel.json file in the terminal\n\nusage\n textel play <file.textel.json> [options]\n\noptions\n --once print one frame and exit (also when output is not a terminal)\n --frame <n> which frame --once prints (default 0)\n --fps <n> playback rate (default: the file's)\n --seconds <n> stop after this long\n --no-color plain characters\n\nkeys space pause \u2190\u2192 step a frame +/- speed q quit\n";
3
+ export declare function run(argv: string[]): Promise<undefined>;
@@ -0,0 +1,55 @@
1
+ import { basename, resolve } from 'node:path';
2
+ import { recordingANSI, recordingText } from "../../frames.js";
3
+ import { loadFrames, play } from "../../terminal.js";
4
+ import { colorDepth, fail, HELP_OPTIONS, num, parse } from "../shared.js";
5
+ export const summary = 'play a .textel.json file in the terminal';
6
+ export const HELP = `textel play: play a .textel.json file in the terminal
7
+
8
+ usage
9
+ textel play <file.textel.json> [options]
10
+
11
+ options
12
+ --once print one frame and exit (also when output is not a terminal)
13
+ --frame <n> which frame --once prints (default 0)
14
+ --fps <n> playback rate (default: the file's)
15
+ --seconds <n> stop after this long
16
+ --no-color plain characters
17
+
18
+ keys space pause ←→ step a frame +/- speed q quit
19
+ `;
20
+ const OPTIONS = {
21
+ ...HELP_OPTIONS,
22
+ once: { type: 'boolean' },
23
+ frame: { type: 'string' },
24
+ fps: { type: 'string' },
25
+ seconds: { type: 'string' },
26
+ 'no-color': { type: 'boolean' },
27
+ };
28
+ export async function run(argv) {
29
+ const { values: o, positionals } = parse(argv, OPTIONS);
30
+ if (o.help || !positionals[0])
31
+ return void process.stdout.write(HELP);
32
+ if (positionals.length > 1)
33
+ fail(`unexpected argument "${positionals[1]}"`);
34
+ const file = resolve(positionals[0]);
35
+ let rec;
36
+ try {
37
+ rec = loadFrames(file);
38
+ }
39
+ catch (e) {
40
+ const err = e;
41
+ fail(err.code === 'ENOENT' ? `cannot read ${positionals[0]}` : `${basename(file)}: ${err.message}`);
42
+ }
43
+ const depth = colorDepth(o['no-color']);
44
+ if (o.once || !process.stdout.isTTY || !process.stdin.isTTY) {
45
+ const i = num(o.frame, '--frame') ?? 0;
46
+ if (!(Number.isInteger(i) && i >= 0 && i < rec.frames.length))
47
+ fail(`--frame is 0 to ${rec.frames.length - 1}`);
48
+ process.stdout.write((depth === 'none' ? recordingText(rec, i) : recordingANSI(rec, i, depth).join('\n')) + '\n');
49
+ return;
50
+ }
51
+ const title = typeof rec.meta.title === 'string' ? rec.meta.title : basename(file);
52
+ const result = await play(rec, { interactive: true, depth, fps: num(o.fps, '--fps'), seconds: num(o.seconds, '--seconds'), title });
53
+ if (result === 'interrupt')
54
+ process.exitCode = 130;
55
+ }
@@ -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;