@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
package/dist/examples.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { clamp, extrude, sdBox, sdCyl, sdfToMeshSteps, sdSphere, sdTorus } from "./sdf.js";
|
|
2
|
+
const HARD_LIGHT = [-0.3, 0.35, 0.88];
|
|
3
|
+
const SOFT_LIGHT = [-0.45, 0.6, 0.65];
|
|
4
|
+
// material indices shared by the examples
|
|
5
|
+
const BODY = 0, CARVED = 1, DIM = 2;
|
|
6
|
+
const MATERIALS = ['body', 'carved', 'dim'];
|
|
7
|
+
const LOOK = { materials: { carved: 'carved', dim: 'dim' } };
|
|
8
|
+
const hard = (pitch) => ({ ...LOOK, charset: 'hard', light: HARD_LIGHT, pitch });
|
|
9
|
+
const soft = (pitch) => ({ ...LOOK, charset: 'classic', light: SOFT_LIGHT, pitch });
|
|
10
|
+
// ---------- donut: the classic ----------
|
|
11
|
+
function* donut() {
|
|
12
|
+
// a torus is symmetric around its own axis, so spinning it flat would look still: the ring is
|
|
13
|
+
// tilted 60° off the spin axis, and the turntable makes it tumble like donut.c
|
|
14
|
+
const t = Math.PI / 3, c = Math.cos(t), s = Math.sin(t);
|
|
15
|
+
return yield* sdfToMeshSteps((x, y, z) => sdTorus(x, y * c - z * s, y * s + z * c, 1, 0.45), {
|
|
16
|
+
min: [-1.5, -1.5, -1.5], max: [1.5, 1.5, 1.5], step: 0.03,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
// ---------- dice: carved pips ----------
|
|
20
|
+
function* dice() {
|
|
21
|
+
const s = 1, pip = 0.17, off = 0.5;
|
|
22
|
+
// pip positions on a face, in face coordinates (u, v), for 1..6
|
|
23
|
+
const FACES = [
|
|
24
|
+
[[0, 0]],
|
|
25
|
+
[[-off, -off], [off, off]],
|
|
26
|
+
[[-off, -off], [0, 0], [off, off]],
|
|
27
|
+
[[-off, -off], [-off, off], [off, -off], [off, off]],
|
|
28
|
+
[[-off, -off], [-off, off], [0, 0], [off, -off], [off, off]],
|
|
29
|
+
[[-off, -off], [-off, 0], [-off, off], [off, -off], [off, 0], [off, off]],
|
|
30
|
+
];
|
|
31
|
+
// face n: axis, sign, and which coordinates span it; opposite faces add up to 7
|
|
32
|
+
const SIDES = [[2, 1, 0, 1], [0, 1, 2, 1], [1, 1, 0, 2], [1, -1, 0, 2], [0, -1, 2, 1], [2, -1, 0, 1]];
|
|
33
|
+
const pipAt = (p) => {
|
|
34
|
+
for (let f = 0; f < 6; f++) {
|
|
35
|
+
const [ax, sg, u, v] = SIDES[f];
|
|
36
|
+
if (p[ax] * sg < s - 0.25)
|
|
37
|
+
continue;
|
|
38
|
+
for (const [pu, pv] of FACES[f])
|
|
39
|
+
if (Math.hypot(p[u] - pu, p[v] - pv) < pip)
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
// pips are shallow dimples pressed into each face
|
|
45
|
+
const field = (x, y, z) => {
|
|
46
|
+
let d = sdBox(x, y, z, s, s, s, 0.18);
|
|
47
|
+
const p = [x, y, z];
|
|
48
|
+
for (let f = 0; f < 6; f++) {
|
|
49
|
+
const [ax, sg, u, v] = SIDES[f];
|
|
50
|
+
for (const [pu, pv] of FACES[f]) {
|
|
51
|
+
const c = [0, 0, 0];
|
|
52
|
+
c[ax] = sg * (s + 0.05);
|
|
53
|
+
c[u] = pu;
|
|
54
|
+
c[v] = pv;
|
|
55
|
+
d = Math.max(d, -(Math.hypot(p[0] - c[0], p[1] - c[1], p[2] - c[2]) - pip - 0.06));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return d;
|
|
59
|
+
};
|
|
60
|
+
return yield* sdfToMeshSteps(field, {
|
|
61
|
+
min: [-1.1, -1.1, -1.1], max: [1.1, 1.1, 1.1], step: 0.025, materials: MATERIALS,
|
|
62
|
+
material: (x, y, z) => (pipAt([x, y, z]) ? CARVED : BODY),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// ---------- rocket: thin fins, a dim window band ----------
|
|
66
|
+
function* rocket() {
|
|
67
|
+
const body = (x, y, z) => {
|
|
68
|
+
const r = Math.hypot(x, z);
|
|
69
|
+
// ogive nose above y = 0.6, straight tube below
|
|
70
|
+
const radius = y > 0.6 ? 0.42 * Math.sqrt(Math.max(0, 1 - ((y - 0.6) / 1.1) ** 2)) : 0.42;
|
|
71
|
+
return Math.max(r - radius, Math.abs(y + 0.15) - 1.85 + (y > 0 ? 0.1 : 0)) * 0.9;
|
|
72
|
+
};
|
|
73
|
+
// three fins around the base
|
|
74
|
+
const fins = (x, y, z) => {
|
|
75
|
+
let d = 1e9;
|
|
76
|
+
for (let k = 0; k < 3; k++) {
|
|
77
|
+
const a = (k / 3) * Math.PI * 2, c = Math.cos(a), s = Math.sin(a);
|
|
78
|
+
const u = x * c + z * s, w = -x * s + z * c;
|
|
79
|
+
const profile = Math.max(-u + 0.3, u - 0.95 + (y + 1.6) * 0.45, -(y + 1.9), y + 0.9 - (0.95 - u) * 0.3);
|
|
80
|
+
d = Math.min(d, extrude(profile, w, 0.035, 0.01));
|
|
81
|
+
}
|
|
82
|
+
return d;
|
|
83
|
+
};
|
|
84
|
+
const nozzle = (x, y, z) => Math.max(sdCyl(x, y + 1.95, z, 0.3 + clamp(-(y + 1.85), 0, 0.2) * 0.6, 0.12), -(Math.hypot(x, z) - 0.2));
|
|
85
|
+
const field = (x, y, z) => Math.min(body(x, y, z), fins(x, y, z), nozzle(x, y, z));
|
|
86
|
+
return yield* sdfToMeshSteps(field, {
|
|
87
|
+
min: [-1.05, -2.1, -1.05], max: [1.05, 1.75, 1.05], step: 0.025, materials: MATERIALS,
|
|
88
|
+
material: (x, y, z) => {
|
|
89
|
+
if (fins(x, y, z) < body(x, y, z) || nozzle(x, y, z) < body(x, y, z))
|
|
90
|
+
return DIM;
|
|
91
|
+
// a porthole facing the viewer and a band below it
|
|
92
|
+
if (z > 0 && Math.hypot(x, y - 0.25) < 0.16)
|
|
93
|
+
return CARVED;
|
|
94
|
+
if (Math.abs(y + 0.55) < 0.06)
|
|
95
|
+
return DIM;
|
|
96
|
+
return BODY;
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
// ---------- planet: a tilted ring ----------
|
|
101
|
+
function* planet() {
|
|
102
|
+
const t = 0.4, ct = Math.cos(t), st = Math.sin(t);
|
|
103
|
+
const ring = (x, y, z) => {
|
|
104
|
+
// ring plane tilted around z
|
|
105
|
+
const ry = x * st + y * ct, rx = x * ct - y * st;
|
|
106
|
+
const r = Math.hypot(rx, z);
|
|
107
|
+
return extrude(Math.abs(r - 1.6) - 0.35, ry, 0.025, 0.01);
|
|
108
|
+
};
|
|
109
|
+
const globe = (x, y, z) => sdSphere(x, y, z, 1);
|
|
110
|
+
return yield* sdfToMeshSteps((x, y, z) => Math.min(globe(x, y, z), ring(x, y, z)), {
|
|
111
|
+
min: [-2.05, -1.2, -2.05], max: [2.05, 1.2, 2.05], step: 0.03, materials: MATERIALS,
|
|
112
|
+
material: (x, y, z) => (ring(x, y, z) < globe(x, y, z) ? (Math.abs(Math.hypot(x * ct - y * st, z) - 1.6) < 0.06 ? CARVED : DIM) : BODY),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// ---------- mug: a hollow inside and a handle ----------
|
|
116
|
+
function* mug() {
|
|
117
|
+
const cup = (x, y, z) => {
|
|
118
|
+
const outer = sdCyl(x, y, z, 0.8, 0.9) - 0.04;
|
|
119
|
+
const inner = sdCyl(x, y - 0.15, z, 0.68, 0.9);
|
|
120
|
+
return Math.max(outer, -inner);
|
|
121
|
+
};
|
|
122
|
+
// the handle is a torus standing up on the side
|
|
123
|
+
const handle = (x, y, z) => Math.max(Math.hypot(Math.hypot(x - 0.85, y) - 0.45, z) - 0.09, 0.78 - x);
|
|
124
|
+
return yield* sdfToMeshSteps((x, y, z) => Math.min(cup(x, y, z), handle(x, y, z)), {
|
|
125
|
+
min: [-0.9, -1, -0.9], max: [1.45, 1, 0.9], step: 0.022, materials: MATERIALS,
|
|
126
|
+
// the inside reads darker
|
|
127
|
+
material: (x, y, z) => (Math.hypot(x, z) < 0.71 && y > -0.7 ? DIM : BODY),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// ---------- gem: flat facets ----------
|
|
131
|
+
function* gem() {
|
|
132
|
+
// a brilliant-ish cut: crown and pavilion made of planes
|
|
133
|
+
const N = 8;
|
|
134
|
+
const field = (x, y, z) => {
|
|
135
|
+
// snap to the nearest facet direction so every face is a plane
|
|
136
|
+
const k = Math.round((Math.atan2(z, x) / (Math.PI * 2)) * N), fa = (k / N) * Math.PI * 2;
|
|
137
|
+
const u = x * Math.cos(fa) + z * Math.sin(fa);
|
|
138
|
+
const girdle = u - 1;
|
|
139
|
+
const crown = u * 0.55 + y - 0.75;
|
|
140
|
+
const table = y - 0.42;
|
|
141
|
+
const pavilion = u * 0.75 - y * 0.66 - 0.75;
|
|
142
|
+
return Math.max(girdle, crown, table, pavilion);
|
|
143
|
+
};
|
|
144
|
+
return yield* sdfToMeshSteps(field, { min: [-1.1, -1.2, -1.1], max: [1.1, 0.5, 1.1], step: 0.02 });
|
|
145
|
+
}
|
|
146
|
+
export const EXAMPLES = [
|
|
147
|
+
{ name: 'donut', title: 'DONUT', description: 'the classic torus', style: { charset: 'classic', pitch: 0.2 }, build: donut },
|
|
148
|
+
{ name: 'dice', title: 'DICE', description: 'a die with carved pips', style: hard(0.45), build: dice },
|
|
149
|
+
{ name: 'rocket', title: 'ROCKET', description: 'thin fins and a porthole', style: soft(0.15), build: rocket },
|
|
150
|
+
{ name: 'planet', title: 'PLANET', description: 'a globe with a tilted ring', style: soft(0.25), build: planet },
|
|
151
|
+
{ name: 'mug', title: 'MUG', description: 'a hollow cup with a handle', style: soft(0.5), build: mug },
|
|
152
|
+
{ name: 'gem', title: 'GEM', description: 'flat facets, good with --shading flat', style: { ...hard(0.35), edges: 'depth' }, build: gem },
|
|
153
|
+
];
|
|
154
|
+
export function findExample(name) {
|
|
155
|
+
const n = name.toLowerCase();
|
|
156
|
+
return EXAMPLES.find((e) => e.name === n || e.title.toLowerCase() === n);
|
|
157
|
+
}
|
|
158
|
+
export function buildExample(e) {
|
|
159
|
+
const g = e.build();
|
|
160
|
+
let r = g.next();
|
|
161
|
+
while (!r.done)
|
|
162
|
+
r = g.next();
|
|
163
|
+
return r.value;
|
|
164
|
+
}
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export declare const FORMAT_VERSION = 1;
|
|
2
|
+
export declare const ENCODING = "rle-delta-1";
|
|
3
|
+
/**
|
|
4
|
+
* Cell styles, by index. `""` is the default color, `"~"` the default color dimmed,
|
|
5
|
+
* `"#rrggbb"` a color. Index 0 is always `""`.
|
|
6
|
+
*/
|
|
7
|
+
export type CellStyle = string;
|
|
8
|
+
/** per-cell palette indices; Uint8Array when the palette fits in a byte */
|
|
9
|
+
export type CellArray = Uint8Array | Uint16Array;
|
|
10
|
+
/** A recorded animation in memory: per frame, one glyph index and one style index per cell. */
|
|
11
|
+
export interface Recording {
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
aspect: number;
|
|
15
|
+
fps: number;
|
|
16
|
+
glyphs: string[];
|
|
17
|
+
/** null when no cell has a color or dim style */
|
|
18
|
+
styles: CellStyle[] | null;
|
|
19
|
+
/** background color of the original style, if it had one */
|
|
20
|
+
bg: string | null;
|
|
21
|
+
frames: {
|
|
22
|
+
glyph: CellArray;
|
|
23
|
+
style: CellArray | null;
|
|
24
|
+
}[];
|
|
25
|
+
meta: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
/** The JSON container. */
|
|
28
|
+
export interface FramesFile {
|
|
29
|
+
textel: number;
|
|
30
|
+
encoding: string;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
aspect: number;
|
|
34
|
+
fps: number;
|
|
35
|
+
frameCount: number;
|
|
36
|
+
glyphs: string[];
|
|
37
|
+
styles?: CellStyle[];
|
|
38
|
+
bg?: string;
|
|
39
|
+
/** base64 of the encoded stream */
|
|
40
|
+
data: string;
|
|
41
|
+
meta?: Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
/** The encoded stream for a recording. Each frame is stored as a delta or a key frame, whichever is smaller. */
|
|
44
|
+
export declare function encodeStream(rec: Recording): Uint8Array;
|
|
45
|
+
/**
|
|
46
|
+
* Decodes the stream. Palette sizes, when given, let the frames use one byte per cell instead of two.
|
|
47
|
+
*/
|
|
48
|
+
export declare function decodeStream(data: Uint8Array, width: number, height: number, frameCount: number, styled: boolean, glyphCount?: number, styleCount?: number): Recording['frames'];
|
|
49
|
+
export declare function toFramesFile(rec: Recording): FramesFile;
|
|
50
|
+
export declare function fromFramesFile(file: FramesFile): Recording;
|
|
51
|
+
/** JSON text of a recording; this is what `.textel.json` holds. */
|
|
52
|
+
export declare function stringifyFrames(rec: Recording): string;
|
|
53
|
+
export declare function parseFrames(text: string): Recording;
|
|
54
|
+
/** one frame as plain text */
|
|
55
|
+
export declare function recordingText(rec: Recording, index: number): string;
|
|
56
|
+
export declare function toBase64(bytes: Uint8Array): string;
|
|
57
|
+
export declare function fromBase64(b64: string): Uint8Array;
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// The frames format: types, the byte stream and the JSON container. No dependencies, so the web
|
|
2
|
+
// player can use it without pulling in the renderer. See docs/format.md for the layout.
|
|
3
|
+
export const FORMAT_VERSION = 1;
|
|
4
|
+
export const ENCODING = 'rle-delta-1';
|
|
5
|
+
// ---------- byte stream ----------
|
|
6
|
+
class Writer {
|
|
7
|
+
buf = new Uint8Array(1024);
|
|
8
|
+
len = 0;
|
|
9
|
+
byte(b) {
|
|
10
|
+
if (this.len === this.buf.length) {
|
|
11
|
+
const nb = new Uint8Array(this.buf.length * 2);
|
|
12
|
+
nb.set(this.buf);
|
|
13
|
+
this.buf = nb;
|
|
14
|
+
}
|
|
15
|
+
this.buf[this.len++] = b;
|
|
16
|
+
}
|
|
17
|
+
/** unsigned LEB128 */
|
|
18
|
+
varint(v) {
|
|
19
|
+
while (v >= 0x80) {
|
|
20
|
+
this.byte((v & 0x7f) | 0x80);
|
|
21
|
+
v >>>= 7;
|
|
22
|
+
}
|
|
23
|
+
this.byte(v);
|
|
24
|
+
}
|
|
25
|
+
bytes() { return this.buf.slice(0, this.len); }
|
|
26
|
+
}
|
|
27
|
+
class Reader {
|
|
28
|
+
pos = 0;
|
|
29
|
+
buf;
|
|
30
|
+
constructor(buf) {
|
|
31
|
+
this.buf = buf;
|
|
32
|
+
}
|
|
33
|
+
byte() {
|
|
34
|
+
if (this.pos >= this.buf.length)
|
|
35
|
+
throw new Error('frames data ends early');
|
|
36
|
+
return this.buf[this.pos++];
|
|
37
|
+
}
|
|
38
|
+
varint() {
|
|
39
|
+
let v = 0, shift = 0, b;
|
|
40
|
+
do {
|
|
41
|
+
b = this.byte();
|
|
42
|
+
v += (b & 0x7f) * 2 ** shift;
|
|
43
|
+
shift += 7;
|
|
44
|
+
if (shift > 35)
|
|
45
|
+
throw new Error('bad varint in frames data');
|
|
46
|
+
} while (b & 0x80);
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
get done() { return this.pos >= this.buf.length; }
|
|
50
|
+
}
|
|
51
|
+
const KEY = 0, DELTA = 1;
|
|
52
|
+
/** RLE over cells [from, to): (run, glyph[, style]) */
|
|
53
|
+
function writeRuns(w, g, s, from, to) {
|
|
54
|
+
let i = from;
|
|
55
|
+
while (i < to) {
|
|
56
|
+
let j = i + 1;
|
|
57
|
+
while (j < to && g[j] === g[i] && (!s || s[j] === s[i]))
|
|
58
|
+
j++;
|
|
59
|
+
w.varint(j - i);
|
|
60
|
+
w.varint(g[i]);
|
|
61
|
+
if (s)
|
|
62
|
+
w.varint(s[i]);
|
|
63
|
+
i = j;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function readRuns(r, g, s, from, count) {
|
|
67
|
+
let i = from;
|
|
68
|
+
const end = from + count;
|
|
69
|
+
if (end > g.length)
|
|
70
|
+
throw new Error('frames data runs past the grid');
|
|
71
|
+
while (i < end) {
|
|
72
|
+
const run = r.varint(), gv = r.varint(), sv = s ? r.varint() : 0;
|
|
73
|
+
if (run === 0 || i + run > end)
|
|
74
|
+
throw new Error('bad run in frames data');
|
|
75
|
+
g.fill(gv, i, i + run);
|
|
76
|
+
if (s)
|
|
77
|
+
s.fill(sv, i, i + run);
|
|
78
|
+
i += run;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function keyFrame(w, g, s) {
|
|
82
|
+
w.byte(KEY);
|
|
83
|
+
writeRuns(w, g, s, 0, g.length);
|
|
84
|
+
}
|
|
85
|
+
/** gaps of unchanged cells shorter than this are resent inside a span; a new span costs more */
|
|
86
|
+
const MIN_GAP = 3;
|
|
87
|
+
/** span count, then per span: (skip, count, runs...) */
|
|
88
|
+
function deltaFrame(w, g, s, pg, ps) {
|
|
89
|
+
const n = g.length, same = (i) => g[i] === pg[i] && (!s || s[i] === ps[i]);
|
|
90
|
+
const spans = [];
|
|
91
|
+
let i = 0;
|
|
92
|
+
while (i < n) {
|
|
93
|
+
while (i < n && same(i))
|
|
94
|
+
i++;
|
|
95
|
+
if (i >= n)
|
|
96
|
+
break;
|
|
97
|
+
const start = i;
|
|
98
|
+
let end = i + 1, j = i + 1;
|
|
99
|
+
while (j < n) {
|
|
100
|
+
if (!same(j)) {
|
|
101
|
+
end = ++j;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
let k = j;
|
|
105
|
+
while (k < n && same(k))
|
|
106
|
+
k++;
|
|
107
|
+
if (k >= n || k - j >= MIN_GAP)
|
|
108
|
+
break;
|
|
109
|
+
j = k;
|
|
110
|
+
}
|
|
111
|
+
spans.push([start, end]);
|
|
112
|
+
i = end;
|
|
113
|
+
}
|
|
114
|
+
w.byte(DELTA);
|
|
115
|
+
w.varint(spans.length);
|
|
116
|
+
let cursor = 0;
|
|
117
|
+
for (const [a, b] of spans) {
|
|
118
|
+
w.varint(a - cursor);
|
|
119
|
+
w.varint(b - a);
|
|
120
|
+
writeRuns(w, g, s, a, b);
|
|
121
|
+
cursor = b;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** The encoded stream for a recording. Each frame is stored as a delta or a key frame, whichever is smaller. */
|
|
125
|
+
export function encodeStream(rec) {
|
|
126
|
+
const out = new Writer();
|
|
127
|
+
let prev = null;
|
|
128
|
+
for (const fr of rec.frames) {
|
|
129
|
+
const k = new Writer();
|
|
130
|
+
keyFrame(k, fr.glyph, fr.style);
|
|
131
|
+
let best = k;
|
|
132
|
+
if (prev) {
|
|
133
|
+
const d = new Writer();
|
|
134
|
+
deltaFrame(d, fr.glyph, fr.style, prev.glyph, prev.style);
|
|
135
|
+
if (d.len < k.len)
|
|
136
|
+
best = d;
|
|
137
|
+
}
|
|
138
|
+
for (let i = 0; i < best.len; i++)
|
|
139
|
+
out.byte(best.buf[i]);
|
|
140
|
+
prev = fr;
|
|
141
|
+
}
|
|
142
|
+
return out.bytes();
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Decodes the stream. Palette sizes, when given, let the frames use one byte per cell instead of two.
|
|
146
|
+
*/
|
|
147
|
+
export function decodeStream(data, width, height, frameCount, styled, glyphCount = 0x10000, styleCount = 0x10000) {
|
|
148
|
+
const G = glyphCount <= 256 ? Uint8Array : Uint16Array, S = styleCount <= 256 ? Uint8Array : Uint16Array;
|
|
149
|
+
const n = width * height, r = new Reader(data), frames = [];
|
|
150
|
+
for (let f = 0; f < frameCount; f++) {
|
|
151
|
+
const type = r.byte(), prev = frames[f - 1];
|
|
152
|
+
const g = new G(n), s = styled ? new S(n) : null;
|
|
153
|
+
if (type === KEY) {
|
|
154
|
+
readRuns(r, g, s, 0, n);
|
|
155
|
+
}
|
|
156
|
+
else if (type === DELTA) {
|
|
157
|
+
if (!prev)
|
|
158
|
+
throw new Error('first frame cannot be a delta');
|
|
159
|
+
g.set(prev.glyph);
|
|
160
|
+
if (s)
|
|
161
|
+
s.set(prev.style);
|
|
162
|
+
const spans = r.varint();
|
|
163
|
+
let i = 0;
|
|
164
|
+
for (let k = 0; k < spans; k++) {
|
|
165
|
+
const skip = r.varint(), count = r.varint();
|
|
166
|
+
if (count === 0)
|
|
167
|
+
throw new Error('empty span in frames data');
|
|
168
|
+
readRuns(r, g, s, i + skip, count);
|
|
169
|
+
i += skip + count;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
throw new Error(`unknown frame type ${type}`);
|
|
174
|
+
}
|
|
175
|
+
frames.push({ glyph: g, style: s });
|
|
176
|
+
}
|
|
177
|
+
if (!r.done)
|
|
178
|
+
throw new Error('extra bytes after the last frame');
|
|
179
|
+
return frames;
|
|
180
|
+
}
|
|
181
|
+
// ---------- containers ----------
|
|
182
|
+
export function toFramesFile(rec) {
|
|
183
|
+
const file = {
|
|
184
|
+
textel: FORMAT_VERSION,
|
|
185
|
+
encoding: ENCODING,
|
|
186
|
+
width: rec.width,
|
|
187
|
+
height: rec.height,
|
|
188
|
+
aspect: rec.aspect,
|
|
189
|
+
fps: rec.fps,
|
|
190
|
+
frameCount: rec.frames.length,
|
|
191
|
+
glyphs: rec.glyphs,
|
|
192
|
+
// header fields first, so the file reads well when opened; the long data string goes last
|
|
193
|
+
...(rec.styles ? { styles: rec.styles } : {}),
|
|
194
|
+
...(rec.bg ? { bg: rec.bg } : {}),
|
|
195
|
+
...(Object.keys(rec.meta).length ? { meta: rec.meta } : {}),
|
|
196
|
+
data: toBase64(encodeStream(rec)),
|
|
197
|
+
};
|
|
198
|
+
return file;
|
|
199
|
+
}
|
|
200
|
+
export function fromFramesFile(file) {
|
|
201
|
+
if (!file || typeof file !== 'object')
|
|
202
|
+
throw new Error('not a textel frames file');
|
|
203
|
+
if (file.textel !== FORMAT_VERSION)
|
|
204
|
+
throw new Error(`frames format ${file.textel} is not supported (this version reads ${FORMAT_VERSION})`);
|
|
205
|
+
if (file.encoding !== ENCODING)
|
|
206
|
+
throw new Error(`unknown frames encoding "${file.encoding}"`);
|
|
207
|
+
const { width, height, frameCount } = file;
|
|
208
|
+
if (!(width > 0 && height > 0 && frameCount > 0))
|
|
209
|
+
throw new Error('bad frames header');
|
|
210
|
+
if (!Array.isArray(file.glyphs) || !file.glyphs.length)
|
|
211
|
+
throw new Error('bad frames header');
|
|
212
|
+
const nG = file.glyphs.length, nS = file.styles?.length ?? 1;
|
|
213
|
+
const frames = decodeStream(fromBase64(file.data), width, height, frameCount, !!file.styles, nG, nS);
|
|
214
|
+
for (const fr of frames) {
|
|
215
|
+
for (let i = 0; i < fr.glyph.length; i++) {
|
|
216
|
+
if (fr.glyph[i] >= nG || (fr.style && fr.style[i] >= nS))
|
|
217
|
+
throw new Error('frames data points outside its palette');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
width, height, aspect: file.aspect, fps: file.fps,
|
|
222
|
+
glyphs: file.glyphs, styles: file.styles ?? null, bg: file.bg ?? null, frames, meta: file.meta ?? {},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** JSON text of a recording; this is what `.textel.json` holds. */
|
|
226
|
+
export function stringifyFrames(rec) {
|
|
227
|
+
return JSON.stringify(toFramesFile(rec));
|
|
228
|
+
}
|
|
229
|
+
export function parseFrames(text) {
|
|
230
|
+
let file;
|
|
231
|
+
try {
|
|
232
|
+
file = JSON.parse(text);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
throw new Error('not a textel frames file (invalid JSON)');
|
|
236
|
+
}
|
|
237
|
+
return fromFramesFile(file);
|
|
238
|
+
}
|
|
239
|
+
/** one frame as plain text */
|
|
240
|
+
export function recordingText(rec, index) {
|
|
241
|
+
const { width: W, height: H, glyphs } = rec, g = rec.frames[index].glyph, rows = [];
|
|
242
|
+
for (let r = 0; r < H; r++) {
|
|
243
|
+
let s = '';
|
|
244
|
+
for (let c = 0; c < W; c++)
|
|
245
|
+
s += glyphs[g[r * W + c]];
|
|
246
|
+
rows.push(s);
|
|
247
|
+
}
|
|
248
|
+
return rows.join('\n');
|
|
249
|
+
}
|
|
250
|
+
// ---------- base64 without Buffer, so the same code runs in browsers ----------
|
|
251
|
+
export function toBase64(bytes) {
|
|
252
|
+
let s = '';
|
|
253
|
+
for (let i = 0; i < bytes.length; i += 0x8000)
|
|
254
|
+
s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
255
|
+
return btoa(s);
|
|
256
|
+
}
|
|
257
|
+
export function fromBase64(b64) {
|
|
258
|
+
const s = atob(b64), out = new Uint8Array(s.length);
|
|
259
|
+
for (let i = 0; i < s.length; i++)
|
|
260
|
+
out[i] = s.charCodeAt(i);
|
|
261
|
+
return out;
|
|
262
|
+
}
|
package/dist/frames.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ColorDepth } from './ansi.ts';
|
|
2
|
+
import type { Recording } from './format.ts';
|
|
3
|
+
import type { Frame, Renderer } from './render.ts';
|
|
4
|
+
export * from './format.ts';
|
|
5
|
+
export interface RecordOptions {
|
|
6
|
+
/** frames in one full turn (default 120) */
|
|
7
|
+
frames?: number;
|
|
8
|
+
/** playback rate (default 30) */
|
|
9
|
+
fps?: number;
|
|
10
|
+
/** turns over the whole loop; negative spins the other way (default 1) */
|
|
11
|
+
turns?: number;
|
|
12
|
+
/** starting yaw, radians */
|
|
13
|
+
yaw?: number;
|
|
14
|
+
/** colors are rounded to multiples of this per channel, which keeps the palette small (default 8) */
|
|
15
|
+
colorStep?: number;
|
|
16
|
+
meta?: Record<string, unknown>;
|
|
17
|
+
/** called after each frame, for progress */
|
|
18
|
+
onFrame?: (done: number, total: number) => void;
|
|
19
|
+
}
|
|
20
|
+
/** Renders a seamless turntable loop: the last frame leads straight back into the first. */
|
|
21
|
+
export declare function recordLoop(renderer: Renderer, opts?: RecordOptions): Recording;
|
|
22
|
+
/** Collects rendered frames into palettes and indices. */
|
|
23
|
+
export declare class RecordingBuilder {
|
|
24
|
+
private glyphs;
|
|
25
|
+
private styles;
|
|
26
|
+
private frames;
|
|
27
|
+
private first;
|
|
28
|
+
private step;
|
|
29
|
+
constructor(colorStep?: number);
|
|
30
|
+
add(frame: Frame): void;
|
|
31
|
+
build(fps: number, meta: Record<string, unknown>): Recording;
|
|
32
|
+
}
|
|
33
|
+
/** one frame as ANSI lines */
|
|
34
|
+
export declare function recordingANSI(rec: Recording, index: number, depth?: ColorDepth): string[];
|
package/dist/frames.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Recording: renders a loop into a frames recording, and plays one back as ANSI.
|
|
2
|
+
// The format itself lives in format.ts.
|
|
3
|
+
import { bg, FAINT, fg, line } from "./ansi.js";
|
|
4
|
+
import { cellColor } from "./output.js";
|
|
5
|
+
export * from "./format.js";
|
|
6
|
+
const hex = (c) => '#' + c.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
|
7
|
+
/** Renders a seamless turntable loop: the last frame leads straight back into the first. */
|
|
8
|
+
export function recordLoop(renderer, opts = {}) {
|
|
9
|
+
const n = Math.max(1, Math.round(opts.frames ?? 120)), turns = opts.turns ?? 1, yaw0 = opts.yaw ?? 0;
|
|
10
|
+
const rec = new RecordingBuilder(opts.colorStep ?? 8);
|
|
11
|
+
for (let i = 0; i < n; i++) {
|
|
12
|
+
rec.add(renderer.render({ yaw: yaw0 + (i / n) * turns * Math.PI * 2 }));
|
|
13
|
+
opts.onFrame?.(i + 1, n);
|
|
14
|
+
}
|
|
15
|
+
return rec.build(opts.fps ?? 30, opts.meta ?? {});
|
|
16
|
+
}
|
|
17
|
+
/** Collects rendered frames into palettes and indices. */
|
|
18
|
+
export class RecordingBuilder {
|
|
19
|
+
glyphs = new Map([[' ', 0]]);
|
|
20
|
+
styles = new Map([['', 0]]);
|
|
21
|
+
frames = [];
|
|
22
|
+
first = null;
|
|
23
|
+
step;
|
|
24
|
+
constructor(colorStep = 8) {
|
|
25
|
+
this.step = Math.max(1, colorStep);
|
|
26
|
+
}
|
|
27
|
+
add(frame) {
|
|
28
|
+
if (!this.first)
|
|
29
|
+
this.first = frame;
|
|
30
|
+
else if (frame.width !== this.first.width || frame.height !== this.first.height)
|
|
31
|
+
throw new Error('all frames must have the same size');
|
|
32
|
+
const n = frame.width * frame.height, glyph = new Uint16Array(n), style = new Uint16Array(n);
|
|
33
|
+
const q = (v) => Math.round(v / this.step) * this.step;
|
|
34
|
+
for (let i = 0; i < n; i++) {
|
|
35
|
+
const ch = frame.chars[i];
|
|
36
|
+
let g = this.glyphs.get(ch);
|
|
37
|
+
if (g === undefined) {
|
|
38
|
+
g = this.glyphs.size;
|
|
39
|
+
this.glyphs.set(ch, g);
|
|
40
|
+
}
|
|
41
|
+
glyph[i] = g;
|
|
42
|
+
if (ch === ' ')
|
|
43
|
+
continue;
|
|
44
|
+
const { rgb, faint } = cellColor(frame, i);
|
|
45
|
+
const key = rgb ? hex([q(rgb[0]), q(rgb[1]), q(rgb[2])]) : faint ? '~' : '';
|
|
46
|
+
let s = this.styles.get(key);
|
|
47
|
+
if (s === undefined) {
|
|
48
|
+
s = this.styles.size;
|
|
49
|
+
this.styles.set(key, s);
|
|
50
|
+
}
|
|
51
|
+
style[i] = s;
|
|
52
|
+
}
|
|
53
|
+
if (this.glyphs.size > 0xffff || this.styles.size > 0xffff)
|
|
54
|
+
throw new Error('too many distinct glyphs or colors');
|
|
55
|
+
this.frames.push({ glyph, style });
|
|
56
|
+
}
|
|
57
|
+
build(fps, meta) {
|
|
58
|
+
const f = this.first;
|
|
59
|
+
if (!f)
|
|
60
|
+
throw new Error('no frames recorded');
|
|
61
|
+
const styled = this.styles.size > 1;
|
|
62
|
+
const bg = f.style.color.bg;
|
|
63
|
+
return {
|
|
64
|
+
width: f.width,
|
|
65
|
+
height: f.height,
|
|
66
|
+
aspect: f.style.aspect,
|
|
67
|
+
fps,
|
|
68
|
+
glyphs: [...this.glyphs.keys()],
|
|
69
|
+
styles: styled ? [...this.styles.keys()] : null,
|
|
70
|
+
bg: bg ? hex(bg) : null,
|
|
71
|
+
frames: this.frames.map((fr) => ({ glyph: fr.glyph, style: styled ? fr.style : null })),
|
|
72
|
+
meta,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const rgbOf = (h) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
|
|
77
|
+
/** one frame as ANSI lines */
|
|
78
|
+
export function recordingANSI(rec, index, depth = 'truecolor') {
|
|
79
|
+
const { width: W, height: H, glyphs } = rec, fr = rec.frames[index];
|
|
80
|
+
const codes = (rec.styles ?? ['']).map((st) => (st === '' ? '' : st === '~' ? FAINT : fg(rgbOf(st), depth)));
|
|
81
|
+
const background = bg(rec.bg ? rgbOf(rec.bg) : null, depth);
|
|
82
|
+
const ch = (i) => glyphs[fr.glyph[i]];
|
|
83
|
+
const code = fr.style ? (i) => codes[fr.style[i]] : () => '';
|
|
84
|
+
const lines = [];
|
|
85
|
+
for (let r = 0; r < H; r++)
|
|
86
|
+
lines.push(line(ch, code, r * W, W, background));
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createRenderer, type Frame, type Renderer, type View } from './render.ts';
|
|
2
|
+
export { toText, toANSI, toHTML, cellColor, type ColorDepth } from './output.ts';
|
|
3
|
+
export { parseModel, parseGLTF, parseOBJ, parseSTL, MODEL_EXTENSIONS } from './loaders/index.ts';
|
|
4
|
+
export { recordLoop, RecordingBuilder, encodeStream, decodeStream, toFramesFile, fromFramesFile, stringifyFrames, parseFrames, recordingText, recordingANSI, FORMAT_VERSION, ENCODING, type Recording, type FramesFile, type RecordOptions, type CellStyle, } from './frames.ts';
|
|
5
|
+
export { MeshBuilder, computeSmoothNormals, vertexCount, triangleCount, type Mesh } from './mesh.ts';
|
|
6
|
+
export { CHARSETS, DEFAULTS, mergeStyles, resolveStyle, type StyleInput, type Style, type MaterialInput, type ColorInput, type EdgeMode, type Vec3, type RGB, } from './style.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createRenderer } from "./render.js";
|
|
2
|
+
export { toText, toANSI, toHTML, cellColor } from "./output.js";
|
|
3
|
+
export { parseModel, parseGLTF, parseOBJ, parseSTL, MODEL_EXTENSIONS } from "./loaders/index.js";
|
|
4
|
+
export { recordLoop, RecordingBuilder, encodeStream, decodeStream, toFramesFile, fromFramesFile, stringifyFrames, parseFrames, recordingText, recordingANSI, FORMAT_VERSION, ENCODING, } from "./frames.js";
|
|
5
|
+
export { MeshBuilder, computeSmoothNormals, vertexCount, triangleCount } from "./mesh.js";
|
|
6
|
+
export { CHARSETS, DEFAULTS, mergeStyles, resolveStyle, } from "./style.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type Mesh } from '../mesh.ts';
|
|
2
|
+
export interface GltfOptions {
|
|
3
|
+
/** loads an external buffer (`.gltf` with a separate `.bin`) by its uri */
|
|
4
|
+
resolve?: (uri: string) => Uint8Array;
|
|
5
|
+
}
|
|
6
|
+
/** `.glb` (binary) or `.gltf` (JSON) bytes. Applies node transforms; all triangle primitives go into one mesh. */
|
|
7
|
+
export declare function parseGLTF(bytes: Uint8Array, opts?: GltfOptions): Mesh;
|